From e80fa4252c6471eda4ec184188b1f35dac2c8ed3 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 17:54:41 -0700 Subject: [PATCH 01/87] =?UTF-8?q?Clean=20cutover=20=E2=80=94=20Kotlin=20of?= =?UTF-8?q?f=20NewPipeExtractor,=20onto=20uniffi.strawcore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Phase U Android-side integration onto a feature branch where the rust wrapper now bridges to strawcore-core (the new NPE port) instead of rustypipe. * strawApp/build.gradle.kts — JNA dep + cargoBuild + cargoBuildHost + uniffiBindgen + the merge/compile task wiring. libs.newpipe .extractor dropped. * StrawApp.kt — uniffi.strawcore.initLogging(); NewPipe.init() gone * 5 ViewModels (Search/VideoDetail/Player/Channel/Subscriptions) swapped to call uniffi.strawcore.{search,streamInfo,channelInfo} * PlayerScreen + PlaybackService adjusted for the new StreamInfo shape (dash_mpd_url / hls_url instead of NPE's manifests) * IosSafeHttpDataSource carried forward — strawcore-core gives us Android-primary URLs so the iOS cap path is mostly dead code, but kept as belt-and-suspenders for the rare HLS-fallback * NewPipeDownloader.kt + util/Thumbnails.kt deleted Files taken wholesale from sulkta branch — the wrapper's UniFFI surface is identical between sulkta (rustypipe-backed) and the current Phase-7-bridged-to-strawcore-core code, so Kotlin doesn't care which extractor is under the hood. --- strawApp/build.gradle.kts | 95 +++++++++- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 14 +- .../straw/extractor/NewPipeDownloader.kt | 96 ---------- .../straw/feature/channel/ChannelViewModel.kt | 62 ++----- .../straw/feature/detail/VideoDetailScreen.kt | 52 ++++-- .../feature/detail/VideoDetailViewModel.kt | 105 +++++------ .../feature/feed/SubscriptionFeedViewModel.kt | 96 +++++----- .../straw/feature/player/PlaybackService.kt | 14 +- .../straw/feature/player/PlayerScreen.kt | 32 +++- .../straw/feature/player/PlayerViewModel.kt | 41 ++--- .../straw/feature/search/SearchViewModel.kt | 44 ++--- .../main/kotlin/com/sulkta/straw/net/Http.kt | 25 +++ .../sulkta/straw/net/IosSafeHttpDataSource.kt | 173 ++++++++++++++++++ .../kotlin/com/sulkta/straw/net/RydClient.kt | 5 +- .../sulkta/straw/net/SponsorBlockClient.kt | 5 +- .../com/sulkta/straw/util/Thumbnails.kt | 24 --- 16 files changed, 516 insertions(+), 367 deletions(-) delete mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt delete mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 13ef39dde..774719420 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -95,7 +95,9 @@ dependencies { implementation(libs.coil.network.okhttp) // NewPipeExtractor (JVM/Android-only) + its OkHttp dep - implementation(libs.newpipe.extractor) + // libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore + // (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild + + // uniffiBindgen Gradle tasks below. implementation(libs.squareup.okhttp) // JSON for SponsorBlock + Return YouTube Dislike clients @@ -110,4 +112,95 @@ dependencies { implementation("androidx.media3:media3-session:1.4.1") // Guava ListenableFuture support for awaiting MediaController connect. implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") + + // strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the + // cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search / + // streamInfo / channelInfo to replace NewPipeExtractor. + implementation("net.java.dev.jna:jna:5.14.0@aar") } + +// ============================================================================= +// Phase U-1 / Path-C-2 — Rust core build glue. +// +// Two tasks chain into the Android build: +// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs +// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/. +// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib +// into strawApp/src/main/java/uniffi/strawcore/. +// +// Both depend on: +// - cargo + rustup with the four Android targets installed +// - cargo-ndk on PATH +// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains +// All of that lives in the crafting-table container. +// ============================================================================= + +val rustRoot = file("../rust").absolutePath +val jniLibsDir = file("src/main/jniLibs").absolutePath +val bindingsDir = file("src/main/java").absolutePath + +val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo" +val cargoBin: String = "$cargoHome/bin/cargo" +val ndkHome: String = System.getenv("ANDROID_NDK_HOME") + ?: System.getenv("ANDROID_NDK_ROOT") + ?: "/caches/android-sdk/ndk/27.2.12479018" +// Honor CARGO_TARGET_DIR if set (we redirect it to /caches on crafting-table +// because the container's writable rootfs hits 100% before the cross-compile +// for 4 ABIs finishes). Falls back to the default `/target`. +val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR") + ?: "$rustRoot/target" + +val cargoBuild by tasks.registering(Exec::class) { + group = "rust" + description = "Cross-compile strawcore for all Android ABIs via cargo-ndk." + workingDir = file(rustRoot) + environment("ANDROID_NDK_HOME", ndkHome) + environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}") + commandLine = listOf( + cargoBin, "ndk", + "-t", "arm64-v8a", + "-t", "armeabi-v7a", + "-t", "x86", + "-t", "x86_64", + "-o", jniLibsDir, + "build", "--release", "-p", "strawcore", + ) + standardOutput = System.out + errorOutput = System.err +} + +val cargoBuildHost by tasks.registering(Exec::class) { + group = "rust" + description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata." + workingDir = file(rustRoot) + environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}") + commandLine = listOf(cargoBin, "build", "-p", "strawcore") + standardOutput = System.out + errorOutput = System.err +} + +val uniffiBindgen by tasks.registering(Exec::class) { + group = "rust" + description = "Generate Kotlin bindings for strawcore via uniffi-bindgen." + dependsOn(cargoBuildHost) + workingDir = file(rustRoot) + environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}") + commandLine = listOf( + cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--", + "generate", + "--library", "$cargoTargetDir/debug/libstrawcore.so", + "--crate", "strawcore", + "--language", "kotlin", + "--no-format", + "--out-dir", bindingsDir, + ) + standardOutput = System.out + errorOutput = System.err +} + +// Make sure Android's JNI-libs merge picks up the freshly built .so files, +// and Kotlin compilation can resolve the generated bindings. +tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") } + .configureEach { dependsOn(cargoBuild) } +tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") } + .configureEach { dependsOn(uniffiBindgen) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 6d8a1defc..7444f3ab8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -9,19 +9,15 @@ import android.app.Application import com.sulkta.straw.data.History import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions -import com.sulkta.straw.extractor.NewPipeDownloader -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization class StrawApp : Application() { override fun onCreate() { super.onCreate() - NewPipe.init( - NewPipeDownloader.init(), - Localization("en", "US"), - ContentCountry("US"), - ) + // Path C-7: route Rust `log::*` calls into Android logcat under tag + // "strawcore". Without this, every log line emitted from rustypipe / + // strawcore is silently dropped, making playback regressions invisible + // from `adb logcat`. + uniffi.strawcore.initLogging() History.init(this) Settings.init(this) Subscriptions.init(this) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt deleted file mode 100644 index bc185b5e0..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Sulkta-Coop - * SPDX-License-Identifier: GPL-3.0-or-later - * - * Minimal OkHttp-backed implementation of NewPipeExtractor's Downloader. - * No cookies, no recaptcha handling — anonymous browsing only. Modeled after - * NewPipe's DownloaderImpl but trimmed down for fork scope. - */ - -package com.sulkta.straw.extractor - -import com.sulkta.straw.net.NEWPIPE_MAX_BYTES -import com.sulkta.straw.net.cappedString -import okhttp3.OkHttpClient -import okhttp3.RequestBody.Companion.toRequestBody -import org.schabi.newpipe.extractor.downloader.Downloader -import org.schabi.newpipe.extractor.downloader.Request -import org.schabi.newpipe.extractor.downloader.Response -import java.io.IOException -import java.util.concurrent.TimeUnit - -class NewPipeDownloader private constructor( - private val client: OkHttpClient, -) : Downloader() { - - override fun execute(request: Request): Response { - val httpMethod = request.httpMethod() - val url = request.url() - val headers = request.headers() - val data: ByteArray? = request.dataToSend() - - val requestBody = data?.toRequestBody(null) - - val okBuilder = okhttp3.Request.Builder() - .method(httpMethod, requestBody) - .url(url) - - // AUD-HIGH: copy NPE headers BEFORE adding our explicit UA so the - // explicit UA wins; guard against header values containing \r/\n - // which OkHttp's addHeader rejects via IAE (turning a poisoned - // response into an app crash). - headers.forEach { (name, values) -> - if (name.equals("User-Agent", ignoreCase = true)) return@forEach - okBuilder.removeHeader(name) - values.forEach { value -> - runCatching { okBuilder.addHeader(name, value) } - } - } - okBuilder.removeHeader("User-Agent") - okBuilder.addHeader("User-Agent", USER_AGENT) - - val okResponse = client.newCall(okBuilder.build()).execute() - val body = okResponse.body - // AUD-HIGH: bounded read to defend against OOM via gigabyte response. - val bodyString = body?.cappedString(NEWPIPE_MAX_BYTES) ?: "" - val responseHeaders = okResponse.headers.toMultimap() - val latestUrl = okResponse.request.url.toString() - if (okResponse.code == 429) { - okResponse.close() - throw IOException("HTTP 429 — rate limited") - } - okResponse.close() - - return Response( - okResponse.code, - okResponse.message, - responseHeaders, - bodyString, - latestUrl, - ) - } - - companion object { - const val USER_AGENT = - "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/120.0.0.0 Mobile Safari/537.36" - - @Volatile private var instance: NewPipeDownloader? = null - - fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader { - val client = (builder ?: OkHttpClient.Builder()) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - val d = NewPipeDownloader(client) - instance = d - return d - } - - fun get(): NewPipeDownloader = instance - ?: error("NewPipeDownloader not initialized — call init() first") - - fun client(): OkHttpClient = get().client - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index e3cb6b2e4..90dd8ea05 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -1,6 +1,10 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore + * (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo + * calls collapse into one Rust round-trip. */ package com.sulkta.straw.feature.channel @@ -8,19 +12,10 @@ package com.sulkta.straw.feature.channel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.feature.search.StreamItem -import com.sulkta.straw.util.bestThumbnail -import kotlinx.coroutines.Dispatchers -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class ChannelUiState( val loading: Boolean = true, @@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() { _ui.value = ChannelUiState(loading = true) viewModelScope.launch { try { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val info = withContext(Dispatchers.IO) { - ChannelInfo.getInfo(service, channelUrl) + val ch = uniffi.strawcore.channelInfo(channelUrl) + val videos = ch.videos.map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader, + uploaderUrl = v.uploaderUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) } - // AUD-HIGH: pick the Videos tab specifically rather than - // info.tabs.firstOrNull() which is YouTube's "Home" (a - // curated mix that mostly drops via filterIsInstance). - val videosTab = info.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: info.tabs.firstOrNull() - val videos: List = if (videosTab != null) { - withContext(Dispatchers.IO) { - runCatching { - ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .map { - StreamItem( - url = it.url, - title = it.name ?: "(no title)", - uploader = it.uploaderName ?: info.name ?: "", - uploaderUrl = it.uploaderUrl ?: channelUrl, - thumbnail = bestThumbnail(it.thumbnails), - durationSeconds = it.duration, - viewCount = it.viewCount, - ) - } - }.getOrDefault(emptyList()) - } - } else emptyList() - _ui.value = ChannelUiState( loading = false, - name = info.name ?: "", - subscriberCount = info.subscriberCount, - banner = bestThumbnail(info.banners), - avatar = bestThumbnail(info.avatars), + name = ch.name, + subscriberCount = ch.subscriberCount, + banner = ch.banner, + avatar = ch.avatar, videos = videos, ) } catch (t: Throwable) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 1720dd4da..ecb92d66e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -69,7 +69,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage -import com.sulkta.straw.extractor.NewPipeDownloader +import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -284,10 +284,8 @@ fun VideoDetailScreen( confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { - val audio = info?.audioStreams - ?.filter { it.content?.isNotBlank() == true } - ?.maxByOrNull { it.bitrate ?: 0 } - ?.content + // info is now uniffi.strawcore.StreamInfo (Path C-4). + val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url if (audio != null) { val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio) val msg = if (id > 0) "audio queued" else "download refused (bad URL)" @@ -298,14 +296,8 @@ fun VideoDetailScreen( showDownloadDialog = false }) { Text("Audio") } Button(onClick = { - val video = info?.videoStreams - ?.filter { it.content?.isNotBlank() == true } - ?.maxByOrNull { it.bitrate ?: 0 } - ?.content - ?: info?.videoOnlyStreams - ?.filter { it.content?.isNotBlank() == true } - ?.maxByOrNull { it.bitrate ?: 0 } - ?.content + val video = info?.combined?.maxByOrNull { it.bitrate }?.url + ?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url if (video != null) { val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video) val msg = if (id > 0) "video queued" else "download refused (bad URL)" @@ -409,16 +401,35 @@ private fun InlinePlayer( .build() } - DisposableEffect(Unit) { - onDispose { exoPlayer.release() } + // Path C-7: surface ExoPlayer failures into UI state. Without this an + // HTTP 403 / source error showed as a stuck black box with the pause + // controls visible — directly enabled a false-positive in the prior + // verification pass. + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(exoPlayer) { + val listener = object : androidx.media3.common.Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = + "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + exoPlayer.release() + } } val resolved = state.resolved LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect - val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs + // don't 403 on first byte. See net/IosSafeHttpDataSource.kt. + val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val source = when { r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) @@ -452,6 +463,11 @@ private fun InlinePlayer( color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp), ) + playbackError != null -> Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) resolved?.isPlayable != true -> Text( "no playable stream", color = Color.White, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 06ee83b6c..63dea79c3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -1,6 +1,10 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-3 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to + * strawcore (Rust + rustypipe via UniFFI). Channel fetch for + * `moreFromChannel` stays on NPE until C-5. */ package com.sulkta.straw.feature.detail @@ -13,20 +17,12 @@ import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydVotes import com.sulkta.straw.net.SponsorBlockClient -import com.sulkta.straw.util.bestThumbnail import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class VideoDetail( val id: String, @@ -48,8 +44,8 @@ data class VideoDetailUiState( val loading: Boolean = true, val detail: VideoDetail? = null, val error: String? = null, - // Stored on success for handoff to player. Not in UI. - val streamInfo: StreamInfo? = null, + // Stored on success for handoff to the player + Download dialog. Not in UI. + val streamInfo: uniffi.strawcore.StreamInfo? = null, ) class VideoDetailViewModel : ViewModel() { @@ -66,11 +62,12 @@ class VideoDetailViewModel : ViewModel() { _ui.value = VideoDetailUiState(loading = true) viewModelScope.launch { try { - val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } + // strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap. + val info = uniffi.strawcore.streamInfo(streamUrl) val videoId = info.id - val thumb = bestThumbnail(info.thumbnails) - val title = info.name ?: "(no title)" - val uploader = info.uploaderName ?: "" + val thumb = info.thumbnail + val title = info.title.ifBlank { "(no title)" } + val uploader = info.uploader runCatching { History.get().recordWatch( @@ -92,51 +89,43 @@ class VideoDetailViewModel : ViewModel() { val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) } - val related = info.relatedItems - ?.filterIsInstance() - ?.map { it -> - com.sulkta.straw.feature.search.StreamItem( - url = it.url, - title = it.name ?: "(no title)", - uploader = it.uploaderName ?: "", - uploaderUrl = it.uploaderUrl, - thumbnail = bestThumbnail(it.thumbnails), - durationSeconds = it.duration, - viewCount = it.viewCount, - ) - } ?: emptyList() - // More from this channel — anchored to the uploader the user - // already chose. Best-effort: empty if the fetch fails so the - // detail screen still renders. Filters out the current video. + // strawcore returns `related` as List. Map to the + // Kotlin StreamItem shape used elsewhere. + val related = info.related.map { r -> + com.sulkta.straw.feature.search.StreamItem( + url = r.url, + title = r.title.ifBlank { "(no title)" }, + uploader = r.uploader, + uploaderUrl = r.uploaderUrl, + thumbnail = r.thumbnail, + durationSeconds = r.durationSeconds, + viewCount = r.viewCount, + ) + } + + // More from this channel via strawcore.channelInfo — one + // Rust round-trip returns the channel's Videos tab pre-mapped. + val uploaderUrl = info.uploaderUrl val moreFromChannel: List = - if (info.uploaderUrl.isNullOrBlank()) emptyList() - else withContext(Dispatchers.IO) { - runCatching { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val ch = ChannelInfo.getInfo(service, info.uploaderUrl) - val videosTab = ch.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: ch.tabs.firstOrNull() - if (videosTab == null) emptyList() - else ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .filter { it.url != streamUrl } - .take(20) - .map { si -> - com.sulkta.straw.feature.search.StreamItem( - url = si.url, - title = si.name ?: "(no title)", - uploader = si.uploaderName ?: uploader, - uploaderUrl = si.uploaderUrl ?: info.uploaderUrl, - thumbnail = bestThumbnail(si.thumbnails), - durationSeconds = si.duration, - viewCount = si.viewCount, - ) - } - }.getOrDefault(emptyList()) - } + if (uploaderUrl.isNullOrBlank()) emptyList() + else runCatching { + val ch = uniffi.strawcore.channelInfo(uploaderUrl) + ch.videos + .filter { it.url != streamUrl } + .take(20) + .map { v -> + com.sulkta.straw.feature.search.StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { uploader }, + uploaderUrl = v.uploaderUrl ?: uploaderUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) + } + }.getOrDefault(emptyList()) _ui.value = VideoDetailUiState( loading = false, @@ -146,7 +135,7 @@ class VideoDetailViewModel : ViewModel() { uploader = uploader, uploaderUrl = info.uploaderUrl, viewCount = info.viewCount, - description = info.description?.content ?: "", + description = info.description, thumbnail = thumb, ryd = ryd, sbSegmentCount = sbCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 93ca74467..d7f27b11c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -3,8 +3,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Phase Q: aggregate latest videos across all subscribed channels into a - * single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS - * fetches in parallel, merges by view count desc, caps at 200 items. + * single feed. Fans out per-channel channelInfo() fetches in parallel, + * merges by view count desc, caps at 200 items. + * + * Path C-5: each per-channel fetch is now ONE strawcore.channelInfo() + * call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo + + * ChannelTabInfo.getInfo). Halves the network work for the feed. * * Audit fixes (2026-05-24 pass #2): * HIGH-6: cancel any prior in-flight refresh when a new one starts, cap @@ -20,9 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem -import com.sulkta.straw.util.bestThumbnail import com.sulkta.straw.util.strawLogW -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -34,14 +36,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class SubscriptionFeedUiState( val loading: Boolean = false, @@ -82,55 +77,46 @@ class SubscriptionFeedViewModel : ViewModel() { _ui.update { it.copy(loading = true, error = null) } inFlight = viewModelScope.launch { try { - val items = withContext(Dispatchers.IO) { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val perChannelMax = 5 - val gate = Semaphore(parallelism) - coroutineScope { - val deferreds = channels.map { ch -> - async { - gate.withPermit { - withTimeoutOrNull(perChannelTimeoutMs) { - runCatching { - val info = ChannelInfo.getInfo(service, ch.url) - val tab = info.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: info.tabs.firstOrNull() - ?: return@runCatching emptyList() - ChannelTabInfo.getInfo(service, tab) - .relatedItems - .filterIsInstance() - .take(perChannelMax) - .map { si -> - StreamItem( - url = si.url, - title = si.name ?: "(no title)", - uploader = si.uploaderName ?: ch.name, - uploaderUrl = si.uploaderUrl ?: ch.url, - thumbnail = bestThumbnail(si.thumbnails), - durationSeconds = si.duration, - viewCount = si.viewCount, - ) - } - }.onFailure { - strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } - }.getOrDefault(emptyList()) - } ?: run { - strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } - emptyList() - } + val perChannelMax = 5 + val gate = Semaphore(parallelism) + val items = coroutineScope { + val deferreds = channels.map { ch -> + async { + gate.withPermit { + withTimeoutOrNull(perChannelTimeoutMs) { + runCatching { + val info = uniffi.strawcore.channelInfo(ch.url) + info.videos + .take(perChannelMax) + .map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { ch.name }, + uploaderUrl = v.uploaderUrl ?: ch.url, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) + } + }.onFailure { + strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } + }.getOrDefault(emptyList()) + } ?: run { + strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } + emptyList() } } } - deferreds.awaitAll() } - .flatten() - // No reliable upload-timestamp from extractor's StreamInfoItem - // in all cases — sort by view count desc as a soft proxy for - // recency-popularity within the recent window. - .sortedByDescending { it.viewCount } - .take(200) + deferreds.awaitAll() } + .flatten() + // No reliable upload-timestamp on the search-item shape — sort + // by view count desc as a soft proxy for recency-popularity + // within the recent window. + .sortedByDescending { it.viewCount } + .take(200) _ui.update { SubscriptionFeedUiState( loading = false, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 40b4083c0..5cf85506c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -50,7 +50,8 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.sulkta.straw.StrawActivity -import com.sulkta.straw.extractor.NewPipeDownloader +import com.sulkta.straw.net.IosSafeHttpDataSource +import com.sulkta.straw.net.STRAW_USER_AGENT @UnstableApi class PlaybackService : MediaSessionService() { @@ -62,9 +63,14 @@ class PlaybackService : MediaSessionService() { super.onCreate() ensureChannel() - val httpFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended + // Range requests get chunked into bounded reads. iOS-bound googlevideo + // URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`. + val httpFactory = IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val mediaSourceFactory = DefaultMediaSourceFactory(this) .setDataSourceFactory(httpFactory) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 1478ca857..caf3d79ef 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -70,7 +70,7 @@ import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView -import com.sulkta.straw.extractor.NewPipeDownloader +import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.net.SbSegment import com.sulkta.straw.util.strawLogI import kotlinx.coroutines.delay @@ -114,6 +114,20 @@ fun PlayerScreen( MediaSession.Builder(context, exoPlayer).build() } + // Path C-7: surface ExoPlayer failures so they don't read as "stuck spinner" + // (Audit Finding 2). Posts to playbackError state which the UI renders. + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(exoPlayer) { + val listener = object : androidx.media3.common.Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = + "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + exoPlayer.addListener(listener) + onDispose { exoPlayer.removeListener(listener) } + } + DisposableEffect(Unit) { onDispose { mediaSession.release() @@ -168,9 +182,13 @@ fun PlayerScreen( LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect - val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs + // don't 403 on first byte. + val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val source = when { r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) @@ -251,6 +269,12 @@ fun PlayerScreen( modifier = Modifier.padding(16.dp), ) + playbackError != null -> Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + resolved?.isPlayable != true -> Text( "no playable stream found", modifier = Modifier.padding(16.dp), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt index ee523f634..5dd9eeaa3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -1,13 +1,16 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-3 / Path C-4: extractor moved from NewPipeExtractor (Java) to + * strawcore (Rust + rustypipe via UniFFI). PlayerScreen still calls + * vm.resolve(url) the same way — the engine underneath flipped. */ package com.sulkta.straw.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.Settings import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SponsorBlockClient @@ -17,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.stream.StreamInfo data class ResolvedPlayback( val title: String, @@ -48,8 +50,11 @@ class PlayerViewModel : ViewModel() { _ui.value = PlayerUiState(loading = true) viewModelScope.launch { try { - val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } + // strawcore.streamInfo is a suspend fun running on the tokio + // runtime baked into libstrawcore.so — no Dispatchers.IO needed. + val info = uniffi.strawcore.streamInfo(streamUrl) val videoId = info.id + val sbCategories = Settings.get().sbCategories.value.map { it.key } val segments = if (sbCategories.isEmpty()) { emptyList() @@ -61,32 +66,24 @@ class PlayerViewModel : ViewModel() { } val maxRes = Settings.get().maxResolution.value.ceiling - fun heightOf(q: String?): Int = - q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 - // Audit HIGH-8: when no stream is under the resolution ceiling - // (e.g. user picked 144p but the video only has 360p+), fall - // back to the lowest-resolution available instead of returning - // null and showing a black-screen player. - fun pickVideo(streams: List?): String? { - if (streams.isNullOrEmpty()) return null - val withContent = streams.filter { it.content?.isNotBlank() == true } - val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes } - val pool = filtered.ifEmpty { withContent } - return pool.maxByOrNull { it.bitrate ?: 0 }?.content + // Audit HIGH-8 carry-over: filter by max resolution but fall + // back to lowest available if the ceiling excludes everything. + fun pickVideo(streams: List): String? { + if (streams.isEmpty()) return null + val filtered = streams.filter { it.height <= maxRes } + val pool = filtered.ifEmpty { streams } + return pool.maxByOrNull { it.bitrate }?.url } - val combined = pickVideo(info.videoStreams) - val videoOnly = pickVideo(info.videoOnlyStreams) - val audioOnly = info.audioStreams - ?.filter { it.content?.isNotBlank() == true } - ?.maxByOrNull { it.bitrate ?: 0 } - ?.content + val combined = pickVideo(info.combined) + val videoOnly = pickVideo(info.videoOnly) + val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url _ui.value = PlayerUiState( loading = false, resolved = ResolvedPlayback( - title = info.name ?: "", + title = info.title, videoUrl = videoOnly, audioUrl = audioOnly, combinedUrl = combined, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 5ef859be4..338388af9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -8,17 +8,10 @@ package com.sulkta.straw.feature.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.History -import com.sulkta.straw.util.bestThumbnail -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.search.SearchInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class SearchUiState( val query: String = "", @@ -52,7 +45,23 @@ class SearchViewModel : ViewModel() { _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList()) viewModelScope.launch { try { - val items = withContext(Dispatchers.IO) { search(q) } + // Phase U-2 / Path C-3: rustypipe via UniFFI. The bindgen-generated + // `search()` is already a suspend fun running on the tokio runtime + // baked into libstrawcore.so — no Dispatchers.IO wrapper needed, + // the JNI call returns to us on the caller dispatcher when the + // future completes. + val rustItems = uniffi.strawcore.search(q) + val items = rustItems.map { r -> + StreamItem( + url = r.url, + title = r.title.ifBlank { "(no title)" }, + uploader = r.uploader, + uploaderUrl = r.uploaderUrl, + thumbnail = r.thumbnail, + durationSeconds = r.durationSeconds, + viewCount = r.viewCount, + ) + } _ui.value = _ui.value.copy(loading = false, results = items) } catch (t: Throwable) { _ui.value = _ui.value.copy( @@ -62,23 +71,4 @@ class SearchViewModel : ViewModel() { } } } - - private fun search(query: String): List { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val qh = service.searchQHFactory.fromQuery(query, emptyList(), "") - val info = SearchInfo.getInfo(service, qh) - return info.relatedItems - .filterIsInstance() - .map { - StreamItem( - url = it.url, - title = it.name ?: "(no title)", - uploader = it.uploaderName ?: "", - uploaderUrl = it.uploaderUrl, - thumbnail = bestThumbnail(it.thumbnails), - durationSeconds = it.duration, - viewCount = it.viewCount, - ) - } - } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt index 4a3524a61..a8d629d86 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt @@ -13,9 +13,34 @@ package com.sulkta.straw.net +import okhttp3.OkHttpClient import okhttp3.ResponseBody import okio.Buffer import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Path C-6 / Phase U-5: USER_AGENT + shared OkHttpClient that previously + * lived on NewPipeDownloader. After ripping NewPipeExtractor, the RYD + + * SponsorBlock + ExoPlayer HTTP factories still need both. One shared + * client is fine. + */ +const val STRAW_USER_AGENT: String = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0" + +@Volatile +private var sharedClient: OkHttpClient? = null + +fun strawHttpClient(): OkHttpClient = + sharedClient ?: synchronized(STRAW_USER_AGENT) { + sharedClient ?: OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + .also { sharedClient = it } + } fun ResponseBody.cappedString(maxBytes: Long): String { val cl = contentLength() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt new file mode 100644 index 000000000..ffb92672f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Path C-7 (post-audit): wrap DefaultHttpDataSource so each open() that + * lacks a bounded length turns into a sequence of bounded Range requests + * (default 1 MiB chunks). + * + * Background: rustypipe's iOS InnerTube client returns pre-signed + * googlevideo URLs. Those URLs reject an open-ended `Range: bytes=N-` + * with HTTP 403 — they only accept bounded `Range: bytes=N-M`. ExoPlayer + * issues open-ended Range requests by default, which made every non-HLS + * iOS-origin video 403 on first byte. This shim makes ExoPlayer + * iOS-shaped without touching media-source selection. + * + * Verified via 2026-05-24 emulator audit (memory/audit-straw-vc16-emulator-2026-05-24.md): + * curl -r 0-1023 → 206 OK + * curl -H "Range: bytes=0-" → 403 (any UA) + * curl no Range → 403 + */ + +package com.sulkta.straw.net + +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource + +private const val TAG = "IosSafeDS" + +@UnstableApi +class IosSafeHttpDataSource( + private val inner: HttpDataSource, + private val chunkBytes: Long = DEFAULT_CHUNK_BYTES, +) : HttpDataSource by inner { + + /** The original (caller-supplied) spec — kept so we can compute the next chunk. */ + private var originalSpec: DataSpec? = null + + /** How many bytes have been read since the caller's open(). */ + private var totalRead: Long = 0 + + /** Bytes left in the current inner-open chunk. -1 = unknown end. */ + private var chunkRemaining: Long = 0 + + override fun open(dataSpec: DataSpec): Long { + // When length is set, respect it but still cap the first inner-open to + // chunkBytes. When length is unset, request a chunk and we'll roll + // forward on subsequent reads. + val requestLen = if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + chunkBytes + } else { + minOf(dataSpec.length, chunkBytes) + } + // NOTE: DataSpec.subrange(offset, length) ADDS offset to the existing + // position — so subrange(position, length) doubles the position. Use + // buildUpon().setLength(...) which preserves position and only bounds + // the byte length. This is what makes ExoPlayer's first Range header + // come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs) + // instead of `bytes=N-` (open, rejected with 403). + val bounded = dataSpec.buildUpon().setLength(requestLen).build() + // Surface itag + mime from query so we can tell video vs audio apart in logs. + val u = dataSpec.uri + val itag = u.getQueryParameter("itag") + val mime = u.getQueryParameter("mime") + Log.i( + TAG, + "open: pos=${bounded.position} len=${bounded.length} " + + "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes) " + + "itag=$itag mime=$mime host=${u.host}", + ) + Log.i(TAG, "open url=${u.toString()}") + originalSpec = dataSpec + totalRead = 0 + // inner.open() returns the BOUNDED chunk's length. Track it so we + // know when to roll to the next chunk. + chunkRemaining = try { + inner.open(bounded) + } catch (t: Throwable) { + Log.w(TAG, "open failed: ${t.javaClass.simpleName}: ${t.message}") + throw t + } + Log.i(TAG, "open: inner returned chunkRemaining=$chunkRemaining") + // Report the original (potentially unbounded) length to the caller — + // ExoPlayer cares about the overall length, not our internal chunking. + return if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + C.LENGTH_UNSET.toLong() + } else { + dataSpec.length + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (length == 0) return 0 + // Need a fresh chunk? + if (chunkRemaining == 0L) { + val spec = originalSpec ?: return C.RESULT_END_OF_INPUT + inner.close() + val nextPos = spec.position + totalRead + val remainingOverall = if (spec.length == C.LENGTH_UNSET.toLong()) { + Long.MAX_VALUE + } else { + spec.length - totalRead + } + if (remainingOverall <= 0L) return C.RESULT_END_OF_INPUT + val nextLen = remainingOverall.coerceAtMost(chunkBytes) + // Same as in open() — use buildUpon().setPosition/setLength rather + // than subrange() so the absolute position stays meaningful. + val nextSpec = spec.buildUpon() + .setPosition(nextPos) + .setLength(nextLen) + .build() + chunkRemaining = inner.open(nextSpec) + } + // Cap the read against what's left in this chunk. + val toRead = if (chunkRemaining < 0L) { + // Inner doesn't know its end either; just read what was asked. + length + } else { + length.toLong().coerceAtMost(chunkRemaining).toInt() + } + val read = inner.read(buffer, offset, toRead) + if (read != C.RESULT_END_OF_INPUT) { + totalRead += read.toLong() + if (chunkRemaining > 0L) chunkRemaining -= read.toLong() + // If chunkRemaining hits 0 here, the next read() call will roll + // to the next chunk via the block at the top. + } else if (chunkRemaining > 0L) { + // Inner ran out before its advertised end. Force chunk roll on + // next read() so we re-open at the next position. + chunkRemaining = 0L + } + return read + } + + override fun close() { + try { + inner.close() + } finally { + originalSpec = null + totalRead = 0 + chunkRemaining = 0 + } + } + + /** Factory: wrap any inner HttpDataSource.Factory. */ + @UnstableApi + class Factory( + private val innerFactory: HttpDataSource.Factory, + private val chunkBytes: Long = DEFAULT_CHUNK_BYTES, + ) : HttpDataSource.Factory { + override fun createDataSource(): HttpDataSource = + IosSafeHttpDataSource(innerFactory.createDataSource(), chunkBytes) + + override fun setDefaultRequestProperties( + defaultRequestProperties: Map, + ): HttpDataSource.Factory { + innerFactory.setDefaultRequestProperties(defaultRequestProperties) + return this + } + } + + companion object { + // YT's iOS-bound googlevideo URLs accept bounded `Range: bytes=N-M` + // requests up to roughly 900 KiB before flipping to 403. Empirically + // measured 2026-05-24 on Lucy egress: bytes=0-917503 (~896 KiB) → 206; + // bytes=0-999999 (~977 KiB) → 403. 512 KiB gives a 2× safety margin — + // small enough to survive future tightening, large enough to keep the + // open() round-trip count tolerable for a long video. + const val DEFAULT_CHUNK_BYTES: Long = 512L * 1024 + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 7c684b4ac..817597596 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -8,7 +8,6 @@ package com.sulkta.straw.net -import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.util.strawLogD import com.sulkta.straw.util.strawLogW import kotlinx.serialization.Serializable @@ -34,11 +33,11 @@ object RydClient { strawLogD(TAG) { "fetch start: $videoId → $url" } val req = Request.Builder() .url(url) - .header("User-Agent", NewPipeDownloader.USER_AGENT) + .header("User-Agent", STRAW_USER_AGENT) .header("Accept", "application/json") .build() return runCatching { - NewPipeDownloader.client().newCall(req).execute().use { r -> + strawHttpClient().newCall(req).execute().use { r -> val code = r.code // AUD-HIGH: bounded body read to defend against OOM. val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: "" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index a5bd4b555..9979e58f8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -8,7 +8,6 @@ package com.sulkta.straw.net -import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.util.strawLogD import com.sulkta.straw.util.strawLogW import kotlinx.serialization.Serializable @@ -47,11 +46,11 @@ object SponsorBlockClient { strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" } val req = Request.Builder() .url(urlStr) - .header("User-Agent", NewPipeDownloader.USER_AGENT) + .header("User-Agent", STRAW_USER_AGENT) .header("Accept", "application/json") .build() return runCatching { - NewPipeDownloader.client().newCall(req).execute().use { r -> + strawHttpClient().newCall(req).execute().use { r -> val code = r.code // AUD-HIGH: bounded body read. val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: "" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt deleted file mode 100644 index 6d47e386b..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Sulkta-Coop - * SPDX-License-Identifier: GPL-3.0-or-later - * - * NewPipeExtractor returns thumbnails as a List with width/height - * fields. Calling .firstOrNull() picks the smallest (the list is sorted - * ascending) — which gave us pixelated thumbnails. This helper picks the - * largest by pixel area instead. - */ - -package com.sulkta.straw.util - -import org.schabi.newpipe.extractor.Image - -fun bestThumbnail(images: List?): String? { - if (images.isNullOrEmpty()) return null - return images - .maxByOrNull { - val w = it.width.takeIf { v -> v > 0 } ?: 0 - val h = it.height.takeIf { v -> v > 0 } ?: 0 - w.toLong() * h.toLong() - } - ?.url -} From f70b8b71b9a876a77d5923dbe9bbdddc26ea34f5 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 18:45:35 -0700 Subject: [PATCH 02/87] v0.1.0-AE (vc=19): rust pipeline cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NCS Spektrem + Rick Astley both play through Rust → ExoPlayer h264 MediaCodec on android-emulator. 4s frame-diff verified, zero PlaybackException. Phase 7 + Phase 8 of the NPE port arc done. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 8577a49eb..ed530789d 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -15,6 +15,12 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw -const val STRAW_VERSION_CODE = 18 -const val STRAW_VERSION_NAME = "0.1.0-AD" +// +// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction goes through +// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no +// NewPipeExtractor in the runtime path. Verified end-to-end on +// android-emulator: NCS Spektrem + Rick Astley both play; 4s frame-diff +// confirmed; zero PlaybackException. +const val STRAW_VERSION_CODE = 19 +const val STRAW_VERSION_NAME = "0.1.0-AE" const val STRAW_APPLICATION_ID = "com.sulkta.straw" From 709af57f42dd186c76a51e88a34d6ae6541a6676 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 20:07:04 -0700 Subject: [PATCH 03/87] v0.1.0-AF (vc=20): channel-videos fix for subscription feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vc=19 shipped with empty subscription feeds because strawcore-core's channel_info was parsing the wrong tab + the wrong renderer type. strawcore-core e6fbbb7 fixes both — second-browse to the Videos tab + parse lockupViewModel. This bump pulls that in. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index ed530789d..1552677c7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -16,11 +16,14 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw // -// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction goes through +// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline +// cutover. vc=19 returned empty subscription feeds because +// strawcore-core's channel_info wasn't doing the second browse for the +// Videos tab AND wasn't parsing the new lockupViewModel shape. +// +// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no -// NewPipeExtractor in the runtime path. Verified end-to-end on -// android-emulator: NCS Spektrem + Rick Astley both play; 4s frame-diff -// confirmed; zero PlaybackException. -const val STRAW_VERSION_CODE = 19 -const val STRAW_VERSION_NAME = "0.1.0-AE" +// NewPipeExtractor in the runtime path. +const val STRAW_VERSION_CODE = 20 +const val STRAW_VERSION_NAME = "0.1.0-AF" const val STRAW_APPLICATION_ID = "com.sulkta.straw" From 599d299b2a0d146d207793e67b57a52b8ae4cd37 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 03:55:39 +0000 Subject: [PATCH 04/87] =?UTF-8?q?vc=3D21:=20seamless=20background-audio=20?= =?UTF-8?q?handoff=20on=20=F0=9F=8E=A7=20+=20HOME=20vc=3D20=20fixed=20chan?= =?UTF-8?q?nel=20videos=20but=20left=20two=20player=20rough=20edges=20that?= =?UTF-8?q?=20Cobb=20called=20out=20on=20the=20phone:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tapping 🎧 (background audio) restarted the stream from the beginning instead of picking up where the foreground player was. * Pressing HOME on the player auto-entered Picture-in-Picture; what Cobb wants is seamless background audio. Both paths now share one handoff: capture exoPlayer.currentPosition, stop the activity player, start PlaybackService with EXTRA_POSITION_MS, and seekTo(position) on setMediaItem. Verified on emulator: 🎧 tap from ~34s in-track resumes service at position=34821ms. HOME-on-player triggers the same path via StrawActivity.onUserLeaveHint → PlayerLeaveHandler.handler (a tiny registry the active PlayerScreen registers in a DisposableEffect; cleared on dispose so the hook is a no-op anywhere else in the app). The previous DisposableEffect that called setAutoEnterEnabled(true) is gone; manual PiP via the ⊟ overlay button stays — that one is still useful and user-triggered. Also fixes a latent IllegalStateException that the new HOME path exposed: PlaybackService and PlayerScreen both built MediaSession with the default empty ID, which the system rejects when both live in the same process. Service now sets .setId("straw-bg") so the two sessions can coexist during the brief activity-vs-service overlap during handoff. Smoke (emulator 1440x3040): * Subscriptions feed renders (vc=20 fix carries over). * 🎧 from ~34s in NCS / Different Heaven → service playing position=34821ms, no session-ID crash. * HOME from PlayerScreen → focus moves to NexusLauncher, PlaybackService running with isForeground=true, pictureInPictureParams=null on the activity (no PiP). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 12 ++++- .../kotlin/com/sulkta/straw/StrawActivity.kt | 11 +++++ .../straw/feature/player/PlaybackService.kt | 10 ++++- .../feature/player/PlayerLeaveHandler.kt | 17 +++++++ .../straw/feature/player/PlayerScreen.kt | 44 ++++++++++--------- 5 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 1552677c7..15a86913b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -16,6 +16,14 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw // +// vc=21 / 0.1.0-AG — player hand-off polish: +// * 🎧 background-audio button now captures the current position and +// resumes the foreground service from there instead of restarting. +// * HOME / recents button while on the player now hands off seamlessly +// to background audio (same position-preserving path) instead of +// auto-entering Picture-in-Picture. Manual PiP via the ⊟ overlay +// button is unchanged. +// // vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline // cutover. vc=19 returned empty subscription feeds because // strawcore-core's channel_info wasn't doing the second browse for the @@ -24,6 +32,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 20 -const val STRAW_VERSION_NAME = "0.1.0-AF" +const val STRAW_VERSION_CODE = 21 +const val STRAW_VERSION_NAME = "0.1.0-AG" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 670db54e3..57746b38c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import com.sulkta.straw.feature.channel.ChannelScreen import com.sulkta.straw.feature.detail.VideoDetailScreen +import com.sulkta.straw.feature.player.PlayerLeaveHandler import com.sulkta.straw.feature.player.PlayerScreen import com.sulkta.straw.feature.search.SearchScreen import com.sulkta.straw.feature.settings.SettingsScreen @@ -109,6 +110,16 @@ class StrawActivity : ComponentActivity() { } } + /** + * HOME / recents → seamless hand-off to background audio when the + * player screen is active. PlayerScreen registers the handler; any + * other screen leaves it null and home behaves normally. + */ + override fun onUserLeaveHint() { + super.onUserLeaveHint() + PlayerLeaveHandler.handler?.invoke() + } + /** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */ private fun pickYouTubeUrl(intent: Intent?): String? { intent ?: return null diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 5cf85506c..8e20dfb04 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -101,7 +101,12 @@ class PlaybackService : MediaSessionService() { PendingIntent.FLAG_IMMUTABLE, ) + // Distinct session ID so we don't collide with the activity-side + // MediaSession (also in this process) when the user hands off from + // PlayerScreen → background audio. Default ID is "" which throws + // IllegalStateException("Session ID must be unique. ID="). mediaSession = MediaSession.Builder(this, player) + .setId(MEDIA_SESSION_ID) .setSessionActivity(sessionActivityIntent) .build() } @@ -122,6 +127,7 @@ class PlaybackService : MediaSessionService() { val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) } val title = intent?.getStringExtra(EXTRA_TITLE) val uploader = intent?.getStringExtra(EXTRA_UPLOADER) + val startPositionMs = intent?.getLongExtra(EXTRA_POSITION_MS, 0L)?.coerceAtLeast(0L) ?: 0L val player = mediaSession?.player if (url == null || player == null) { // HIGH-2: nothing to play (likely a re-launch with null intent @@ -139,7 +145,7 @@ class PlaybackService : MediaSessionService() { .build(), ) .build() - player.setMediaItem(item) + player.setMediaItem(item, startPositionMs) player.prepare() player.playWhenReady = true return START_NOT_STICKY @@ -231,8 +237,10 @@ class PlaybackService : MediaSessionService() { const val EXTRA_URL = "com.sulkta.straw.extra.URL" const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE" const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER" + const val EXTRA_POSITION_MS = "com.sulkta.straw.extra.POSITION_MS" private const val NOTIF_CHANNEL_ID = "straw.playback" private const val NOTIF_ID = 4242 + private const val MEDIA_SESSION_ID = "straw-bg" } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt new file mode 100644 index 000000000..e7f57495a --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Bridge between StrawActivity.onUserLeaveHint() (HOME / recents button) + * and the active PlayerScreen. When the user leaves the player by pressing + * HOME, we want a seamless hand-off to the background-audio foreground + * service — not Picture-in-Picture. PlayerScreen registers a handler on + * compose, clears it on dispose; the activity calls it from the OS hook. + */ + +package com.sulkta.straw.feature.player + +object PlayerLeaveHandler { + @Volatile + var handler: (() -> Unit)? = null +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index caf3d79ef..8a7e6c1ad 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -135,29 +135,29 @@ fun PlayerScreen( } } - // PiP setup: on Android 12+ tell the OS this activity can auto-enter - // PiP, so when the user presses Home or swipes away the video shrinks - // into a floating window instead of pausing/exiting. Aspect ratio is - // set eagerly so the system can sample it before the first transition. - val activity = context as? Activity - DisposableEffect(activity) { - if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .setAutoEnterEnabled(true) - .build() - runCatching { activity.setPictureInPictureParams(params) } - } - onDispose { - // Disable auto-enter when leaving the player so the rest of the - // app doesn't accidentally PiP on background. - if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val off = PictureInPictureParams.Builder() - .setAutoEnterEnabled(false) - .build() - runCatching { activity.setPictureInPictureParams(off) } + // Home / recents button → seamless hand-off to background audio service + // instead of Picture-in-Picture. PiP is still available manually via the + // ⊟ overlay button. We register a handler that the activity calls from + // onUserLeaveHint(); the handler captures currentPosition so the audio + // service resumes from the same point. Same code path that the explicit + // 🎧 button uses. + val resolvedState = androidx.compose.runtime.rememberUpdatedState(state.resolved) + DisposableEffect(Unit) { + PlayerLeaveHandler.handler = handler@{ + val r = resolvedState.value ?: return@handler + val audio = r.audioUrl ?: r.combinedUrl ?: return@handler + val position = exoPlayer.currentPosition.coerceAtLeast(0L) + runCatching { exoPlayer.stop() } + runCatching { exoPlayer.clearMediaItems() } + val intent = Intent(context, PlaybackService::class.java).apply { + component = ComponentName(context, PlaybackService::class.java) + putExtra(PlaybackService.EXTRA_URL, audio) + putExtra(PlaybackService.EXTRA_TITLE, title) + putExtra(PlaybackService.EXTRA_POSITION_MS, position) } + ContextCompat.startForegroundService(context, intent) } + onDispose { PlayerLeaveHandler.handler = null } } // AUD-MED: pause playback when app goes to background. Without this, @@ -382,12 +382,14 @@ fun PlayerScreen( Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show() return@OverlayButton } + val position = exoPlayer.currentPosition.coerceAtLeast(0L) runCatching { exoPlayer.stop() } runCatching { exoPlayer.clearMediaItems() } val intent = Intent(context, PlaybackService::class.java).apply { component = ComponentName(context, PlaybackService::class.java) putExtra(PlaybackService.EXTRA_URL, audio) putExtra(PlaybackService.EXTRA_TITLE, title) + putExtra(PlaybackService.EXTRA_POSITION_MS, position) } ContextCompat.startForegroundService(context, intent) Toast.makeText( From e7d45aa6b4f9827b5ceaaef0a2c998b81ce86edd Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 15:57:56 +0000 Subject: [PATCH 05/87] =?UTF-8?q?vc=3D22:=20inline=E2=86=92fullscreen=20po?= =?UTF-8?q?sition=20handoff=20+=20local=20playlists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildSrc/src/main/kotlin/ProjectConfig.kt | 13 +- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 8 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 20 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../main/kotlin/com/sulkta/straw/StrawHome.kt | 11 + .../com/sulkta/straw/data/PlaylistsStore.kt | 126 +++++++++ .../straw/feature/detail/VideoDetailScreen.kt | 128 ++++++++- .../straw/feature/player/PlayerScreen.kt | 8 + .../straw/feature/playlist/PlaylistsScreen.kt | 267 ++++++++++++++++++ 9 files changed, 575 insertions(+), 8 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 15a86913b..3106cdb6b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -16,6 +16,15 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw // +// vc=22 / 0.1.0-AH — V-2 player polish + local playlists: +// * Inline → fullscreen now hands off seek position. Tap Play (or the +// ⛶ pill on the inline player) while the inline is mid-track and +// the fullscreen Player picks up at the same point. Same handoff +// pattern as fullscreen → background from vc=21. +// * Local playlists: drawer entry "Playlists", "Save" button on +// VideoDetail. SharedPreferences-backed, no queue/autoplay yet +// (tap an entry to open VideoDetail as normal). +// // vc=21 / 0.1.0-AG — player hand-off polish: // * 🎧 background-audio button now captures the current position and // resumes the foreground service from there instead of restarting. @@ -32,6 +41,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 21 -const val STRAW_VERSION_NAME = "0.1.0-AG" +const val STRAW_VERSION_CODE = 22 +const val STRAW_VERSION_NAME = "0.1.0-AH" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 02396749c..3e5c7edf6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -16,9 +16,15 @@ sealed interface Screen { data object Home : Screen data object Search : Screen data object Settings : Screen + data object Playlists : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen - data class Player(val streamUrl: String, val title: String) : Screen + data class Player( + val streamUrl: String, + val title: String, + val startPositionMs: Long = 0L, + ) : Screen data class Channel(val channelUrl: String, val name: String) : Screen + data class PlaylistView(val playlistId: String, val name: String) : Screen } class Navigator(initial: Screen) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 57746b38c..340dcf846 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -23,6 +23,8 @@ import com.sulkta.straw.feature.channel.ChannelScreen import com.sulkta.straw.feature.detail.VideoDetailScreen import com.sulkta.straw.feature.player.PlayerLeaveHandler import com.sulkta.straw.feature.player.PlayerScreen +import com.sulkta.straw.feature.playlist.PlaylistViewScreen +import com.sulkta.straw.feature.playlist.PlaylistsScreen import com.sulkta.straw.feature.search.SearchScreen import com.sulkta.straw.feature.settings.SettingsScreen @@ -67,6 +69,7 @@ class StrawActivity : ComponentActivity() { is Screen.Home -> StrawHome( onOpenSearch = { nav.push(Screen.Search) }, onOpenSettings = { nav.push(Screen.Settings) }, + onOpenPlaylists = { nav.push(Screen.Playlists) }, onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, @@ -83,8 +86,8 @@ class StrawActivity : ComponentActivity() { is Screen.VideoDetail -> VideoDetailScreen( streamUrl = s.streamUrl, initialTitle = s.title, - onPlay = { - nav.push(Screen.Player(s.streamUrl, s.title)) + onPlay = { startPositionMs -> + nav.push(Screen.Player(s.streamUrl, s.title, startPositionMs)) }, onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) @@ -103,6 +106,19 @@ class StrawActivity : ComponentActivity() { is Screen.Player -> PlayerScreen( streamUrl = s.streamUrl, title = s.title, + startPositionMs = s.startPositionMs, + ) + is Screen.Playlists -> PlaylistsScreen( + onOpenPlaylist = { id, name -> + nav.push(Screen.PlaylistView(id, name)) + }, + ) + is Screen.PlaylistView -> PlaylistViewScreen( + playlistId = s.playlistId, + initialName = s.name, + onOpenVideo = { url, title -> + nav.push(Screen.VideoDetail(url, title)) + }, ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 7444f3ab8..ca36407c9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -7,6 +7,7 @@ package com.sulkta.straw import android.app.Application import com.sulkta.straw.data.History +import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions @@ -21,5 +22,6 @@ class StrawApp : Application() { History.init(this) Settings.init(this) Subscriptions.init(this) + Playlists.init(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 31e1f2452..2e72d6ab6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -78,6 +78,7 @@ private enum class HomeView { Subs, History } fun StrawHome( onOpenSearch: () -> Unit, onOpenSettings: () -> Unit, + onOpenPlaylists: () -> Unit, onOpenVideo: (url: String, title: String) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, feedVm: SubscriptionFeedViewModel = viewModel(), @@ -125,6 +126,16 @@ fun StrawHome( }, modifier = Modifier.padding(horizontal = 12.dp), ) + NavigationDrawerItem( + label = { Text("Playlists") }, + icon = { Text("📃") }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + onOpenPlaylists() + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) NavigationDrawerItem( label = { Text("Settings") }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt new file mode 100644 index 000000000..cf72bd597 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * SharedPreferences-lite local playlists. User creates a playlist + * ("study music", "boss fight rage"), saves videos to it from + * VideoDetailScreen, and replays them later from the drawer. Same + * persistence pattern as SubscriptionsStore — JSON blob in + * SharedPreferences, atomic updates via updateAndGet so concurrent + * "save to playlist" taps don't lose entries. + * + * No queue-autoplay yet — tapping a video in a playlist navigates to + * VideoDetail like normal. Queue handoff would be its own task. + */ + +package com.sulkta.straw.data + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.UUID + +@Serializable +data class PlaylistItem( + val streamUrl: String, + val title: String, + val thumbnail: String? = null, + val uploader: String = "", + val addedAt: Long = 0L, +) + +@Serializable +data class Playlist( + val id: String, + val name: String, + val createdAt: Long, + val items: List = emptyList(), +) + +private const val PREFS = "straw_playlists" +private const val KEY = "playlists_v1" + +class PlaylistsStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val _playlists = MutableStateFlow(load()) + val playlists: StateFlow> = _playlists.asStateFlow() + + fun create(name: String): Playlist { + val pl = Playlist( + id = UUID.randomUUID().toString(), + name = name.trim().ifBlank { "Untitled" }, + createdAt = System.currentTimeMillis(), + ) + val next = _playlists.updateAndGet { it + pl } + persist(next) + return pl + } + + fun delete(id: String) { + val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } } + persist(next) + } + + fun rename(id: String, newName: String) { + val trimmed = newName.trim().ifBlank { return } + val next = _playlists.updateAndGet { cur -> + cur.map { if (it.id == id) it.copy(name = trimmed) else it } + } + persist(next) + } + + fun addItem(playlistId: String, item: PlaylistItem) { + val stamped = item.copy(addedAt = System.currentTimeMillis()) + val next = _playlists.updateAndGet { cur -> + cur.map { pl -> + if (pl.id != playlistId) pl + else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl + else pl.copy(items = pl.items + stamped) + } + } + persist(next) + } + + fun removeItem(playlistId: String, streamUrl: String) { + val next = _playlists.updateAndGet { cur -> + cur.map { pl -> + if (pl.id != playlistId) pl + else pl.copy(items = pl.items.filterNot { it.streamUrl == streamUrl }) + } + } + persist(next) + } + + fun get(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id } + + private fun persist(list: List) { + sp.edit().putString(KEY, json.encodeToString(list)).apply() + } + + private fun load(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(s) + }.getOrDefault(emptyList()) +} + +object Playlists { + @Volatile private var instance: PlaylistsStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = PlaylistsStore(context.applicationContext) + } + } + } + + fun get(): PlaylistsStore = instance + ?: error("PlaylistsStore not initialized — call Playlists.init(context)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index ecb92d66e..01ea63ef1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -27,18 +27,25 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import android.content.Intent import android.widget.Toast import androidx.annotation.OptIn import androidx.compose.material3.AlertDialog import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.sulkta.straw.data.PlaylistItem +import com.sulkta.straw.data.Playlists +import kotlinx.coroutines.delay import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import com.sulkta.straw.feature.download.DownloadKind @@ -78,7 +85,7 @@ import com.sulkta.straw.util.stripHtml fun VideoDetailScreen( streamUrl: String, initialTitle: String, - onPlay: () -> Unit, + onPlay: (startPositionMs: Long) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit, vm: VideoDetailViewModel = viewModel(), @@ -86,9 +93,14 @@ fun VideoDetailScreen( val state by vm.ui.collectAsStateWithLifecycle() val context = LocalContext.current var showDownloadDialog by remember { mutableStateOf(false) } + var showSaveToPlaylistDialog by remember { mutableStateOf(false) } // Inline-play state. Resets when the user navigates to a different // video (keyed on streamUrl). var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } + // V-2: inline player's current position, polled into here so the + // outer can pass it through when the user taps Play / ⛶. Resets to 0 + // when the inline player isn't active. + var inlinePositionMs by remember(streamUrl) { mutableLongStateOf(0L) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } Column( @@ -117,7 +129,8 @@ fun VideoDetailScreen( if (inlinePlaying) { InlinePlayer( streamUrl = streamUrl, - onFullscreen = onPlay, + onFullscreen = { onPlay(inlinePositionMs) }, + onPositionChanged = { inlinePositionMs = it }, modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) @@ -209,7 +222,7 @@ fun VideoDetailScreen( Spacer(modifier = Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onPlay) { Text("Play") } + Button(onClick = { onPlay(inlinePositionMs) }) { Text("Play") } OutlinedButton(onClick = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -221,6 +234,9 @@ fun VideoDetailScreen( OutlinedButton(onClick = { showDownloadDialog = true }) { Text("Download") } + OutlinedButton(onClick = { showSaveToPlaylistDialog = true }) { + Text("Save") + } } Spacer(modifier = Modifier.height(20.dp)) @@ -265,6 +281,18 @@ fun VideoDetailScreen( } } + if (showSaveToPlaylistDialog) { + SaveToPlaylistDialog( + item = PlaylistItem( + streamUrl = streamUrl, + title = d.title, + thumbnail = d.thumbnail, + uploader = d.uploader, + ), + onDismiss = { showSaveToPlaylistDialog = false }, + ) + } + if (showDownloadDialog) { val info = state.streamInfo AlertDialog( @@ -369,6 +397,90 @@ private fun RelatedRow( } } +@Composable +private fun SaveToPlaylistDialog( + item: PlaylistItem, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val store = Playlists.get() + val playlists by store.playlists.collectAsState() + var creatingNew by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Save to playlist") }, + text = { + Column { + if (playlists.isEmpty() && !creatingNew) { + Text( + "No playlists yet. Create one to save this video.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + playlists.forEach { pl -> + val already = pl.items.any { it.streamUrl == item.streamUrl } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !already) { + store.addItem(pl.id, item) + Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show() + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(pl.name, style = MaterialTheme.typography.bodyLarge) + Text( + "${pl.items.size} video${if (pl.items.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + if (creatingNew) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("New playlist name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + val pl = store.create(newName) + store.addItem(pl.id, item) + Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show() + onDismiss() + }) { Text("Create + save") } + OutlinedButton(onClick = { creatingNew = false; newName = "" }) { + Text("Cancel") + } + } + } else { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = { creatingNew = true }) { + Text("+ New playlist") + } + } + } + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + /** * Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen. * Uses its own ExoPlayer + PlayerView (with the built-in controller for @@ -382,6 +494,7 @@ private fun RelatedRow( private fun InlinePlayer( streamUrl: String, onFullscreen: () -> Unit, + onPositionChanged: (Long) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -455,6 +568,15 @@ private fun InlinePlayer( } } + // V-2: report inline position to the parent so the Play / ⛶ button + // can pick up where playback was when the user goes fullscreen. + LaunchedEffect(exoPlayer) { + while (true) { + onPositionChanged(exoPlayer.currentPosition.coerceAtLeast(0L)) + delay(500) + } + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { when { state.loading -> CircularProgressIndicator(color = Color.White) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 8a7e6c1ad..748c61379 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -80,6 +80,7 @@ import kotlinx.coroutines.delay fun PlayerScreen( streamUrl: String, title: String, + startPositionMs: Long = 0L, vm: PlayerViewModel = viewModel(), ) { val context = LocalContext.current @@ -216,6 +217,13 @@ fun PlayerScreen( if (source != null) { exoPlayer.setMediaSource(source) + // V-2: when we navigate here from an inline player that was + // already playing, pick up at the same position instead of + // restarting. seekTo() before prepare() is allowed; the seek + // is queued and applied once the player is ready. + if (startPositionMs > 0) { + exoPlayer.seekTo(startPositionMs) + } exoPlayer.prepare() exoPlayer.playWhenReady = true } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt new file mode 100644 index 000000000..44c1ad36f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Two-screen unit for local playlists: + * PlaylistsScreen — root list of all user playlists (drawer entry) + * PlaylistViewScreen — items inside one playlist, tap to open + */ + +package com.sulkta.straw.feature.playlist + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.sulkta.straw.data.Playlists + +@Composable +fun PlaylistsScreen( + onOpenPlaylist: (id: String, name: String) -> Unit, +) { + val store = Playlists.get() + val playlists by store.playlists.collectAsState() + var showCreate by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + var pendingDelete by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Playlists", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Button(onClick = { showCreate = true; newName = "" }) { Text("+ New") } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "${playlists.size} playlist${if (playlists.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (playlists.isEmpty()) { + Text( + "No playlists yet. Tap + New, or use the Save button on a video.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn { + items(playlists, key = { it.id }) { pl -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenPlaylist(pl.id, pl.name) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(56.dp) + .height(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text("📃") + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + pl.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${pl.items.size} video${if (pl.items.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton(onClick = { pendingDelete = pl.id }) { + Text("Delete") + } + } + HorizontalDivider() + } + } + } + + if (showCreate) { + AlertDialog( + onDismissRequest = { showCreate = false }, + title = { Text("New playlist") }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Button(onClick = { + store.create(newName) + showCreate = false + }) { Text("Create") } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { showCreate = false }) { + Text("Cancel") + } + }, + ) + } + + pendingDelete?.let { id -> + val name = store.get(id)?.name ?: "this playlist" + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Delete \"$name\"?") }, + text = { Text("This removes the playlist and its saved video references. Doesn't delete any downloaded files.") }, + confirmButton = { + Button(onClick = { + store.delete(id) + pendingDelete = null + }) { Text("Delete") } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { pendingDelete = null }) { + Text("Cancel") + } + }, + ) + } + } +} + +@Composable +fun PlaylistViewScreen( + playlistId: String, + initialName: String, + onOpenVideo: (url: String, title: String) -> Unit, +) { + val store = Playlists.get() + val playlists by store.playlists.collectAsState() + val playlist = playlists.firstOrNull { it.id == playlistId } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text( + playlist?.name ?: initialName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (playlist == null) { + Text( + "Playlist not found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + Text( + "${playlist.items.size} video${if (playlist.items.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (playlist.items.isEmpty()) { + Text( + "Empty. Tap Save on a video to add it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn { + items(playlist.items, key = { it.streamUrl }) { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenVideo(item.streamUrl, item.title) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.Top, + ) { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = Modifier + .width(140.dp) + .height(80.dp) + .clip(RoundedCornerShape(6.dp)), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + item.uploader, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + androidx.compose.material3.TextButton(onClick = { + store.removeItem(playlist.id, item.streamUrl) + }) { Text("×") } + } + HorizontalDivider() + } + } + } + } +} From 1be4c4265f2bce68dd53258b2803a23c6bbda35b Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 16:23:05 +0000 Subject: [PATCH 06/87] vc=23: minibar + MediaController unification + Downloads UI + green theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real MediaController/MediaSessionService unification: a single ExoPlayer owned by PlaybackService, every UI surface is a MediaController client. Playback never restarts on screen transitions. Drops the per-screen ExoPlayer instances; drops the EXTRA_URL Intent-based handoff from vc=21. Minibar overlay: persistent strip pinned to the bottom of every non-Player screen whenever something is loaded. Tap to expand to fullscreen, x to stop and clear, play/pause toggles. Drag-down on the fullscreen player or the down-chevron overlay button minimizes into the minibar. Single source of truth for what is playing is NowPlaying — a process-wide StateFlow refreshed by whichever surface calls setPlayingFrom. Custom MediaSource.Factory in the service routes DASH/HLS/progressive by MIME, and merges video+audio progressives via a side-channel EXTRA_AUDIO_URL bundle on the MediaItem. SponsorBlock skip loop is now activity-scoped, hoisted out of PlayerScreen, so segments are skipped in minibar mode too. Downloads tab wired into the drawer. Reads DownloadManager every second, shows status + progress, tap to open, x to remove. Theme: forest-green primary palette replaces the M3 default lavender / NewPipe red. Modern, clean, distinct. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 18 +- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 1 + .../kotlin/com/sulkta/straw/StrawActivity.kt | 78 +++- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 11 + .../kotlin/com/sulkta/straw/StrawTheme.kt | 64 +++ .../straw/feature/detail/VideoDetailScreen.kt | 124 ++---- .../straw/feature/download/DownloadsScreen.kt | 247 ++++++++++ .../straw/feature/player/MinibarOverlay.kt | 141 ++++++ .../sulkta/straw/feature/player/NowPlaying.kt | 42 ++ .../straw/feature/player/PlaybackService.kt | 271 +++++------ .../straw/feature/player/PlayerScreen.kt | 420 +++++++----------- .../feature/player/StrawMediaController.kt | 144 ++++++ 12 files changed, 1067 insertions(+), 494 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 3106cdb6b..14969bfa5 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -16,6 +16,20 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw // +// vc=23 / 0.1.0-AI — minibar + downloads UI + green theme: +// * MediaController/MediaSessionService unification — single ExoPlayer +// owned by PlaybackService, every UI surface is a controller client. +// Inline player on VideoDetail, fullscreen Player, and the new +// minibar overlay all drive the same underlying player; nothing +// restarts on screen transitions. +// * Persistent minibar overlay at the bottom of every non-Player +// screen whenever something is loaded. Tap → expand to fullscreen. +// Drag-down on fullscreen → minimize to minibar. ⌄ overlay button +// also minimizes. × on the minibar stops + clears. +// * Downloads page wired into the drawer. +// * Theme: forest-green primary palette in place of M3 default +// lavender / NewPipe red — modern, clean, distinct. +// // vc=22 / 0.1.0-AH — V-2 player polish + local playlists: // * Inline → fullscreen now hands off seek position. Tap Play (or the // ⛶ pill on the inline player) while the inline is mid-track and @@ -41,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 22 -const val STRAW_VERSION_NAME = "0.1.0-AH" +const val STRAW_VERSION_CODE = 23 +const val STRAW_VERSION_NAME = "0.1.0-AI" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 3e5c7edf6..bf91616fa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -17,6 +17,7 @@ sealed interface Screen { data object Search : Screen data object Settings : Screen data object Playlists : Screen + data object Downloads : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen data class Player( val streamUrl: String, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 340dcf846..7d16e4650 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -12,17 +12,25 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.media3.common.util.UnstableApi import com.sulkta.straw.feature.channel.ChannelScreen import com.sulkta.straw.feature.detail.VideoDetailScreen +import com.sulkta.straw.feature.download.DownloadsScreen +import com.sulkta.straw.feature.player.LocalStrawController +import com.sulkta.straw.feature.player.MinibarOverlay import com.sulkta.straw.feature.player.PlayerLeaveHandler import com.sulkta.straw.feature.player.PlayerScreen +import com.sulkta.straw.feature.player.SponsorBlockSkipLoop +import com.sulkta.straw.feature.player.rememberStrawController import com.sulkta.straw.feature.playlist.PlaylistViewScreen import com.sulkta.straw.feature.playlist.PlaylistsScreen import com.sulkta.straw.feature.search.SearchScreen @@ -38,6 +46,7 @@ private val YT_URL_RE = Regex( ) class StrawActivity : ComponentActivity() { + @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -45,8 +54,14 @@ class StrawActivity : ComponentActivity() { val startUrl = pickYouTubeUrl(intent) setContent { - val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors() + // Build one MediaController for the whole activity. Every screen + // pulls it via LocalStrawController, every PlayerView binds to + // it, and the minibar overlay (rendered below) uses it too. + // Single player, single source of truth. + val controller = rememberStrawController() MaterialTheme(colorScheme = scheme) { + CompositionLocalProvider(LocalStrawController provides controller) { Surface(modifier = Modifier.fillMaxSize()) { val initial: Screen = if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home @@ -65,11 +80,47 @@ class StrawActivity : ComponentActivity() { onDispose { cb.remove() } } - when (val s = nav.current) { + // SponsorBlock skip loop runs at the activity level so it + // applies whether the user is fullscreen, in the minibar, + // or away from the player surface. + SponsorBlockSkipLoop() + + Box(modifier = Modifier.fillMaxSize()) { + ScreenContent(nav, s = nav.current) + // Persistent minibar overlay — visible on every screen + // except Player itself (fullscreen has its own UI). + if (nav.current !is Screen.Player) { + MinibarOverlay( + onExpand = { + val item = com.sulkta.straw.feature.player.NowPlaying.current.value + if (item != null) { + nav.push( + Screen.Player( + item.streamUrl, + item.title, + controller?.currentPosition ?: 0L, + ) + ) + } + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + } + } + } + } + } + + @Composable + private fun ScreenContent(nav: Navigator, s: Screen) { + when (s) { is Screen.Home -> StrawHome( onOpenSearch = { nav.push(Screen.Search) }, onOpenSettings = { nav.push(Screen.Settings) }, onOpenPlaylists = { nav.push(Screen.Playlists) }, + onOpenDownloads = { nav.push(Screen.Downloads) }, onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, @@ -77,6 +128,7 @@ class StrawActivity : ComponentActivity() { nav.push(Screen.Channel(url, name)) }, ) + is Screen.Downloads -> DownloadsScreen() is Screen.Settings -> SettingsScreen() is Screen.Search -> SearchScreen( onOpenVideo = { url, title -> @@ -107,22 +159,20 @@ class StrawActivity : ComponentActivity() { streamUrl = s.streamUrl, title = s.title, startPositionMs = s.startPositionMs, + onMinimize = { nav.pop() }, ) is Screen.Playlists -> PlaylistsScreen( onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) }, ) - is Screen.PlaylistView -> PlaylistViewScreen( - playlistId = s.playlistId, - initialName = s.name, - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, - ) - } - } - } + is Screen.PlaylistView -> PlaylistViewScreen( + playlistId = s.playlistId, + initialName = s.name, + onOpenVideo = { url, title -> + nav.push(Screen.VideoDetail(url, title)) + }, + ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 2e72d6ab6..0512de4aa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -79,6 +79,7 @@ fun StrawHome( onOpenSearch: () -> Unit, onOpenSettings: () -> Unit, onOpenPlaylists: () -> Unit, + onOpenDownloads: () -> Unit, onOpenVideo: (url: String, title: String) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, feedVm: SubscriptionFeedViewModel = viewModel(), @@ -136,6 +137,16 @@ fun StrawHome( }, modifier = Modifier.padding(horizontal = 12.dp), ) + NavigationDrawerItem( + label = { Text("Downloads") }, + icon = { Text("⬇") }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + onOpenDownloads() + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) NavigationDrawerItem( label = { Text("Settings") }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt new file mode 100644 index 000000000..74ad7be59 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Sulkta green palette for Straw. Replaces the M3 default lavender/red + * tints with a clean forest green primary — modern, clean, distinct from + * NewPipe / Tubular's red. Same Tonal Palette structure Material 3 uses + * internally so all the derived surfaces stay in harmony. + */ + +package com.sulkta.straw + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +private val GreenPrimary = Color(0xFF386A1F) +private val GreenOnPrimary = Color(0xFFFFFFFF) +private val GreenPrimaryContainer = Color(0xFFB6F397) +private val GreenOnPrimaryContainer = Color(0xFF0B2000) +private val GreenSecondary = Color(0xFF55624C) +private val GreenOnSecondary = Color(0xFFFFFFFF) +private val GreenSecondaryContainer = Color(0xFFD8E7CB) +private val GreenOnSecondaryContainer = Color(0xFF131F0D) +private val GreenTertiary = Color(0xFF386666) +private val GreenOnTertiary = Color(0xFFFFFFFF) + +private val DarkGreenPrimary = Color(0xFF9BD67D) +private val DarkGreenOnPrimary = Color(0xFF153800) +private val DarkGreenPrimaryContainer = Color(0xFF205107) +private val DarkGreenOnPrimaryContainer = Color(0xFFB6F397) +private val DarkGreenSecondary = Color(0xFFBDCBB0) +private val DarkGreenOnSecondary = Color(0xFF283420) +private val DarkGreenSecondaryContainer = Color(0xFF3E4A35) +private val DarkGreenOnSecondaryContainer = Color(0xFFD8E7CB) +private val DarkGreenTertiary = Color(0xFFA0CFD0) +private val DarkGreenOnTertiary = Color(0xFF003738) + +fun strawLightColors(): ColorScheme = lightColorScheme( + primary = GreenPrimary, + onPrimary = GreenOnPrimary, + primaryContainer = GreenPrimaryContainer, + onPrimaryContainer = GreenOnPrimaryContainer, + secondary = GreenSecondary, + onSecondary = GreenOnSecondary, + secondaryContainer = GreenSecondaryContainer, + onSecondaryContainer = GreenOnSecondaryContainer, + tertiary = GreenTertiary, + onTertiary = GreenOnTertiary, +) + +fun strawDarkColors(): ColorScheme = darkColorScheme( + primary = DarkGreenPrimary, + onPrimary = DarkGreenOnPrimary, + primaryContainer = DarkGreenPrimaryContainer, + onPrimaryContainer = DarkGreenOnPrimaryContainer, + secondary = DarkGreenSecondary, + onSecondary = DarkGreenOnSecondary, + secondaryContainer = DarkGreenSecondaryContainer, + onSecondaryContainer = DarkGreenOnSecondaryContainer, + tertiary = DarkGreenTertiary, + onTertiary = DarkGreenOnTertiary, +) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 01ea63ef1..3f93f1df6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -64,19 +64,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.dash.DashMediaSource -import androidx.media3.exoplayer.hls.HlsMediaSource -import androidx.media3.exoplayer.source.MergingMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage -import com.sulkta.straw.net.STRAW_USER_AGENT +import com.sulkta.straw.feature.player.LocalStrawController +import com.sulkta.straw.feature.player.NowPlaying +import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -129,6 +123,9 @@ fun VideoDetailScreen( if (inlinePlaying) { InlinePlayer( streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, onFullscreen = { onPlay(inlinePositionMs) }, onPositionChanged = { inlinePositionMs = it }, modifier = Modifier @@ -493,93 +490,63 @@ private fun SaveToPlaylistDialog( @Composable private fun InlinePlayer( streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, onFullscreen: () -> Unit, onPositionChanged: (Long) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current + val controller = LocalStrawController.current val playerVm: PlayerViewModel = viewModel() val state by playerVm.ui.collectAsStateWithLifecycle() LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) } - val exoPlayer = remember { - ExoPlayer.Builder(context) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .build(), - /* handleAudioFocus = */ true, - ) - .build() - } - - // Path C-7: surface ExoPlayer failures into UI state. Without this an - // HTTP 403 / source error showed as a stuck black box with the pause - // controls visible — directly enabled a false-positive in the prior - // verification pass. - var playbackError by remember { mutableStateOf(null) } - DisposableEffect(exoPlayer) { - val listener = object : androidx.media3.common.Player.Listener { - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - playbackError = - "${error.errorCodeName}: ${error.message ?: "(no message)"}" - } - } - exoPlayer.addListener(listener) - onDispose { - exoPlayer.removeListener(listener) - exoPlayer.release() - } - } - + // As soon as we have a resolved stream AND the active video isn't + // already this URL, push it into the shared controller. The controller + // is the same one driving the fullscreen Player + the minibar overlay, + // so playback survives any nav transition unchanged. val resolved = state.resolved - LaunchedEffect(resolved) { + LaunchedEffect(controller, resolved, streamUrl) { + val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect - // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs - // don't 403 on first byte. See net/IosSafeHttpDataSource.kt. - val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( - DefaultHttpDataSource.Factory() - .setUserAgent(STRAW_USER_AGENT) - .setAllowCrossProtocolRedirects(true) - ) - val source = when { - r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) - r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.hlsUrl)) - r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.combinedUrl)) - r.videoUrl != null && r.audioUrl != null -> { - val v = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.videoUrl)) - val a = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.audioUrl)) - MergingMediaSource(v, a) - } - r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.videoUrl)) - else -> null - } - if (source != null) { - exoPlayer.setMediaSource(source) - exoPlayer.prepare() - exoPlayer.playWhenReady = true + val activeUrl = NowPlaying.current.value?.streamUrl + if (activeUrl != streamUrl) { + c.setPlayingFrom( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + resolved = r, + ) } } - // V-2: report inline position to the parent so the Play / ⛶ button - // can pick up where playback was when the user goes fullscreen. - LaunchedEffect(exoPlayer) { + // Report position to the parent on every tick so a Play / ⛶ tap picks + // up at the right spot if the active video is somehow desynced. + LaunchedEffect(controller) { + val c = controller ?: return@LaunchedEffect while (true) { - onPositionChanged(exoPlayer.currentPosition.coerceAtLeast(0L)) + onPositionChanged(c.currentPosition.coerceAtLeast(0L)) delay(500) } } + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(controller) { + val c = controller + val listener = object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + c?.addListener(listener) + onDispose { c?.removeListener(listener) } + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { when { - state.loading -> CircularProgressIndicator(color = Color.White) + controller == null || state.loading -> CircularProgressIndicator(color = Color.White) state.error != null -> Text( "playback error: ${state.error}", color = MaterialTheme.colorScheme.error, @@ -599,10 +566,11 @@ private fun InlinePlayer( AndroidView( factory = { ctx -> PlayerView(ctx).apply { - player = exoPlayer + player = controller useController = true } }, + update = { it.player = controller }, modifier = Modifier.fillMaxSize(), ) // Top-right fullscreen pill — hops to the fullscreen diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt new file mode 100644 index 000000000..f3262cd41 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Downloads tab — lists everything Phase R's Downloader handed off to + * Android's DownloadManager. Reads live from DownloadManager.query() + * keyed by package owner, so we naturally show only this app's queue. + * + * Row shows: title, kind (audio / video), state (running / completed / + * failed), and progress / size. Tap a completed row → ACTION_VIEW + * intent to whatever player the user has registered. × removes the + * entry from the queue (and the file, per DownloadManager.remove + * semantics). + */ + +package com.sulkta.straw.feature.download + +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.net.Uri +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.height +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.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +data class DownloadRow( + val id: Long, + val title: String, + val localUri: String?, + val mediaType: String?, + val status: Int, + val reason: Int, + val bytesSoFar: Long, + val totalBytes: Long, +) { + val progressFraction: Float? + get() = if (totalBytes > 0) (bytesSoFar.toFloat() / totalBytes).coerceIn(0f, 1f) else null + + val statusLabel: String + get() = when (status) { + DownloadManager.STATUS_RUNNING -> "downloading" + DownloadManager.STATUS_PENDING -> "pending" + DownloadManager.STATUS_PAUSED -> "paused" + DownloadManager.STATUS_SUCCESSFUL -> "done" + DownloadManager.STATUS_FAILED -> "failed" + else -> "unknown" + } +} + +@Composable +fun DownloadsScreen() { + val context = LocalContext.current + var rows by remember { mutableStateOf>(emptyList()) } + + // Poll DownloadManager every second while the screen is visible. + // DownloadManager doesn't broadcast progress, so polling is the + // standard pattern. Cheap query — single cursor across the app's own + // download queue. + LaunchedEffect(Unit) { + while (true) { + rows = queryDownloads(context) + delay(1000) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text( + "Downloads", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "${rows.size} item${if (rows.size == 1) "" else "s"} · saved to app private storage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (rows.isEmpty()) { + Text( + "Nothing here yet. Tap Download on any video.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn { + items(rows, key = { it.id }) { row -> + DownloadRowView(row, context, onRemove = { + runCatching { + (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager) + .remove(row.id) + } + rows = rows.filterNot { it.id == row.id } + }) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun DownloadRowView( + row: DownloadRow, + context: Context, + onRemove: () -> Unit, +) { + val openable = row.status == DownloadManager.STATUS_SUCCESSFUL && !row.localUri.isNullOrBlank() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = openable) { + row.localUri?.let { uri -> + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + runCatching { context.startActivity(intent) } + } + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(width = 56.dp, height = 56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text(if (row.mediaType?.startsWith("audio") == true) "🎵" else "🎬") + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + row.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + buildString { + append(row.statusLabel) + if (row.totalBytes > 0) { + append(" · ") + append(formatBytes(row.bytesSoFar)) + append(" / ") + append(formatBytes(row.totalBytes)) + } else if (row.bytesSoFar > 0) { + append(" · ") + append(formatBytes(row.bytesSoFar)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + row.progressFraction?.takeIf { row.status != DownloadManager.STATUS_SUCCESSFUL } + ?.let { p -> + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { p }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + TextButton(onClick = onRemove) { Text("×") } + } +} + +private fun queryDownloads(context: Context): List { + val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager + ?: return emptyList() + val query = DownloadManager.Query() + val out = mutableListOf() + runCatching { dm.query(query) }.getOrNull()?.use { c -> + val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID) + val titleIdx = c.getColumnIndex(DownloadManager.COLUMN_TITLE) + val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + val mimeIdx = c.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE) + val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS) + val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON) + val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + while (c.moveToNext()) { + out += DownloadRow( + id = c.getLong(idIdx), + title = c.getString(titleIdx) ?: "(no title)", + localUri = c.getString(uriIdx), + mediaType = c.getString(mimeIdx), + status = c.getInt(statusIdx), + reason = c.getInt(reasonIdx), + bytesSoFar = c.getLong(soFarIdx), + totalBytes = c.getLong(totalIdx), + ) + } + } + return out.sortedByDescending { it.id } +} + +private fun formatBytes(b: Long): String = when { + b < 1024 -> "$b B" + b < 1024L * 1024 -> "${b / 1024} KB" + b < 1024L * 1024 * 1024 -> "%.1f MB".format(b.toDouble() / (1024 * 1024)) + else -> "%.2f GB".format(b.toDouble() / (1024L * 1024 * 1024)) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt new file mode 100644 index 000000000..b1d98bcb6 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The minibar: a thin persistent strip pinned to the bottom of every + * non-Player screen whenever a video is loaded into the MediaController. + * Tap to expand back to fullscreen. The × clears playback and dismisses. + * + * The actual player + audio lives in PlaybackService — this composable + * is purely UI on top of the MediaController. Pause/play toggles the + * controller, which is the same player feeding the fullscreen surface + * and the inline detail player. There is only ever one player. + */ + +package com.sulkta.straw.feature.player + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import coil3.compose.AsyncImage + +@OptIn(UnstableApi::class) +@Composable +fun MinibarOverlay( + onExpand: () -> Unit, + modifier: Modifier = Modifier, +) { + val controller = LocalStrawController.current + val item by NowPlaying.current.collectAsStateWithLifecycle() + if (controller == null || item == null) return + val cur = item ?: return + + // Reflect the controller's play state in the play/pause icon. Listening + // is the only reliable way; isPlaying snapshots stale between events. + var isPlaying by remember { mutableStateOf(controller.isPlaying) } + DisposableEffect(controller) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + } + controller.addListener(listener) + isPlaying = controller.isPlaying + onDispose { controller.removeListener(listener) } + } + + Column(modifier = modifier.fillMaxWidth()) { + HorizontalDivider() + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable(onClick = onExpand), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + AsyncImage( + model = cur.thumbnail, + contentDescription = null, + modifier = Modifier + .size(width = 80.dp, height = 48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + cur.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + cur.uploader, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MinibarIconButton(label = if (isPlaying) "⏸" else "▶") { + if (controller.isPlaying) controller.pause() else controller.play() + } + MinibarIconButton(label = "×") { + controller.stop() + controller.clearMediaItems() + NowPlaying.clear() + } + } + } + } + } +} + +@Composable +private fun MinibarIconButton(label: String, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(22.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.titleMedium) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt new file mode 100644 index 000000000..75e00f29c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Singleton "currently active video" state — drives the minibar overlay + * and tells screens whether their video matches what's playing. Updated + * by whichever surface starts playback (VideoDetail tap, Player Play + * button, playlist item tap). Cleared by the minibar's × button. + * + * Why a process-wide singleton instead of a ViewModel: the minibar is + * rendered at the activity layout level and needs to outlive any + * specific Screen.* composable. Same shape as Subscriptions / Playlists + * — runtime-only here since there's no persistence (session-scoped). + */ + +package com.sulkta.straw.feature.player + +import com.sulkta.straw.net.SbSegment +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class NowPlayingItem( + val streamUrl: String, + val title: String, + val uploader: String, + val thumbnail: String?, + val segments: List = emptyList(), +) + +object NowPlaying { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current.asStateFlow() + + fun set(item: NowPlayingItem?) { + _current.value = item + } + + fun clear() { + _current.value = null + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 8e20dfb04..61e0f1c47 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -2,51 +2,46 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Phase S: foreground-service ExoPlayer for "Background" audio mode. - * Independent of the activity-side player. When the user taps Background - * on the player overlay, the activity stops its own playback and starts - * this service with the audio URL. Audio continues even if the activity - * is killed (swipe out of recents). + * Universal player for Straw. Owns the single ExoPlayer + MediaSession. + * Every UI surface (inline player on VideoDetail, fullscreen PlayerScreen, + * the minibar overlay) is a MediaController client talking to this + * session — so playback never restarts on a screen transition and a + * dragged-down player just keeps going at the bottom of the layout. * - * Audit fixes (2026-05-24 pass #2): - * CRIT-1: call startForeground() immediately on first onStartCommand so - * Android 12+ doesn't kill the process with - * ForegroundServiceDidNotStartInTimeException after the 5s window. - * HIGH-2: return START_NOT_STICKY when there is no playable URL — the - * OS will not relaunch us with a null intent and crash-loop. - * HIGH-3: stop the service when playback ends (Player.Listener) so the - * WAKE_LOCK / foreground notification doesn't linger. - * MED-1: null the field before releasing the session to close a tiny - * onGetSession race during teardown. + * The service is brought up automatically the first time the activity + * builds a MediaController against `SessionToken(ctx, ComponentName)`. + * It transitions to foreground when playback starts (Media3 handles the + * required notification); it stops itself when idle (no controllers + * connected AND nothing in the queue). * - * Limitations: - * - Single URL only. The activity-side merged-DASH path doesn't carry - * over (we just use the best audioStream). Acceptable trade-off for - * background mode. - * - No SponsorBlock skip here. That logic lives in PlayerScreen and is - * foreground-only for now. - * - Service plays one item at a time. Queue/playlist is future work. + * Media source dispatch lives in [StrawMediaSourceFactory] below. It + * routes by MIME type for DASH / HLS / progressive and merges video + + * audio when the audio URL is carried in the MediaItem's + * `requestMetadata.extras[EXTRA_AUDIO_URL]`. */ package com.sulkta.straw.feature.player -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent -import android.content.pm.ServiceInfo import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.sulkta.straw.StrawActivity @@ -57,54 +52,51 @@ import com.sulkta.straw.net.STRAW_USER_AGENT class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null - private var foregroundStarted = false override fun onCreate() { super.onCreate() - ensureChannel() // Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended - // Range requests get chunked into bounded reads. iOS-bound googlevideo - // URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`. + // Range requests get chunked into bounded reads. iOS-bound + // googlevideo URLs 403 on `Range: bytes=N-` but accept `Range: + // bytes=N-M`. val httpFactory = IosSafeHttpDataSource.Factory( DefaultHttpDataSource.Factory() .setUserAgent(STRAW_USER_AGENT) .setAllowCrossProtocolRedirects(true) ) - val mediaSourceFactory = DefaultMediaSourceFactory(this) - .setDataSourceFactory(httpFactory) + + val mediaSourceFactory = StrawMediaSourceFactory(httpFactory) val player = ExoPlayer.Builder(this) .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build(), /* handleAudioFocus = */ true, ) .build() - // HIGH-3: end-of-playback should release the foreground slot. + // Stop ourselves once playback genuinely ends so the foreground slot + // is released. STATE_IDLE + STATE_ENDED both qualify; STATE_BUFFERING + // / STATE_READY mean we're still doing work even if paused. player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { - if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) { - stopSelf() - } + if (state == Player.STATE_ENDED) stopSelfWhenIdle() } }) val sessionActivityIntent = PendingIntent.getActivity( this, 0, - Intent(this, StrawActivity::class.java), + Intent(this, StrawActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + }, PendingIntent.FLAG_IMMUTABLE, ) - // Distinct session ID so we don't collide with the activity-side - // MediaSession (also in this process) when the user hands off from - // PlayerScreen → background audio. Default ID is "" which throws - // IllegalStateException("Session ID must be unique. ID="). mediaSession = MediaSession.Builder(this, player) .setId(MEDIA_SESSION_ID) .setSessionActivity(sessionActivityIntent) @@ -115,58 +107,24 @@ class PlaybackService : MediaSessionService() { controllerInfo: MediaSession.ControllerInfo, ): MediaSession? = mediaSession - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - // CRIT-1: must startForeground within ~5s of startForegroundService, - // before anything that can throw or block. - startForegroundCompat() - - val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) } - val title = intent?.getStringExtra(EXTRA_TITLE) - val uploader = intent?.getStringExtra(EXTRA_UPLOADER) - val startPositionMs = intent?.getLongExtra(EXTRA_POSITION_MS, 0L)?.coerceAtLeast(0L) ?: 0L - val player = mediaSession?.player - if (url == null || player == null) { - // HIGH-2: nothing to play (likely a re-launch with null intent - // after a kill). Tear down so we don't sit holding the FG slot. - stopSelf() - return START_NOT_STICKY - } - - val item = MediaItem.Builder() - .setUri(url) - .setMediaMetadata( - androidx.media3.common.MediaMetadata.Builder() - .setTitle(title ?: "") - .setArtist(uploader ?: "") - .build(), - ) - .build() - player.setMediaItem(item, startPositionMs) - player.prepare() - player.playWhenReady = true - return START_NOT_STICKY - } - + /** + * When the user swipes the app out of Recents, only kill the service + * if playback isn't running. If the user is intentionally backgrounding + * to keep music going, we stay alive. + */ override fun onTaskRemoved(rootIntent: Intent?) { - // HIGH-3: keep service alive ONLY while playback is genuinely in - // progress. After STATE_ENDED, playWhenReady stays true but state - // is ENDED — old check missed that and held WAKE_LOCK forever. val p = mediaSession?.player - val keep = p != null && + val keepAlive = p != null && p.playWhenReady && p.mediaItemCount > 0 && p.playbackState != Player.STATE_IDLE && p.playbackState != Player.STATE_ENDED - if (!keep) stopSelf() + if (!keepAlive) stopSelf() } override fun onDestroy() { - // MED-1: null the field first so a late onGetSession from the - // controller-binding teardown gets null instead of a released session. + // Null the field first so a late onGetSession during teardown gets + // null rather than a released session. val s = mediaSession mediaSession = null s?.player?.release() @@ -174,73 +132,86 @@ class PlaybackService : MediaSessionService() { super.onDestroy() } - private fun startForegroundCompat() { - if (foregroundStarted) return - val tap = PendingIntent.getActivity( - this, - 0, - Intent(this, StrawActivity::class.java), - PendingIntent.FLAG_IMMUTABLE, - ) - val notification: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_media_play) - .setContentTitle("Straw") - .setContentText("Background audio") - .setContentIntent(tap) - .setOngoing(true) - .setCategory(Notification.CATEGORY_TRANSPORT) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIF_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, - ) - } else { - startForeground(NOTIF_ID, notification) + private fun stopSelfWhenIdle() { + val p = mediaSession?.player ?: return + if (p.mediaItemCount == 0 || p.playbackState == Player.STATE_IDLE) { + stopSelf() } - foregroundStarted = true - } - - private fun ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val nm = getSystemService(NotificationManager::class.java) ?: return - if (nm.getNotificationChannel(NOTIF_CHANNEL_ID) != null) return - val ch = NotificationChannel( - NOTIF_CHANNEL_ID, - "Background audio", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Straw audio playback while the app is in background" - setShowBadge(false) - } - nm.createNotificationChannel(ch) - } - - /** - * HIGH-4 mirror on the service side: the URL in EXTRA_URL came from - * NewPipeExtractor's audioStream.content. Re-validate host + scheme - * before handing it to ExoPlayer's HTTP source. Only YT googlevideo - * hosts allowed; HTTPS only. - */ - private fun isAllowedAudioUrl(url: String): Boolean { - val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false - if (!uri.scheme.equals("https", ignoreCase = true)) return false - val host = uri.host?.lowercase() ?: return false - return host.endsWith(".googlevideo.com") || - host.endsWith(".youtube.com") || - host == "youtube.com" } companion object { - const val EXTRA_URL = "com.sulkta.straw.extra.URL" - const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE" - const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER" - const val EXTRA_POSITION_MS = "com.sulkta.straw.extra.POSITION_MS" + const val MEDIA_SESSION_ID = "straw" - private const val NOTIF_CHANNEL_ID = "straw.playback" - private const val NOTIF_ID = 4242 - private const val MEDIA_SESSION_ID = "straw-bg" + /** + * Bundle key — when set on a MediaItem's `requestMetadata.extras`, + * the source factory will merge that audio URL with the + * MediaItem's video URI to produce a combined video+audio source. + */ + const val EXTRA_AUDIO_URL = "straw.audio_url" } } + +/** + * MediaSource.Factory that picks the right inner source per MediaItem: + * + * - If `requestMetadata.extras[EXTRA_AUDIO_URL]` is set → MergingMediaSource + * (progressive video + progressive audio). + * - Else by MIME: application/dash+xml → DASH, application/x-mpegURL → HLS, + * everything else → progressive. + * + * Lets us drive all stream shapes (DASH MPD, HLS, combined progressive, + * separate video+audio progressive) through the single MediaController API + * without exposing MediaSource directly to the UI layer. + */ +@UnstableApi +class StrawMediaSourceFactory( + private val dataSourceFactory: DataSource.Factory, +) : MediaSource.Factory { + private val dashFactory = DashMediaSource.Factory(dataSourceFactory) + private val hlsFactory = HlsMediaSource.Factory(dataSourceFactory) + private val progFactory = ProgressiveMediaSource.Factory(dataSourceFactory) + // For mime-sniffing fallthroughs we also fall back to DefaultMediaSourceFactory + // so things like extractors-only progressive items keep working. + private val defaultFactory = DefaultMediaSourceFactory(dataSourceFactory) + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + val audioUrl = mediaItem.requestMetadata.extras + ?.getString(PlaybackService.EXTRA_AUDIO_URL) + if (audioUrl != null) { + val videoSource = progFactory.createMediaSource(mediaItem) + val audioSource = progFactory.createMediaSource(MediaItem.fromUri(Uri.parse(audioUrl))) + return MergingMediaSource(videoSource, audioSource) + } + val mime = mediaItem.localConfiguration?.mimeType + return when (mime) { + MimeTypes.APPLICATION_MPD -> dashFactory.createMediaSource(mediaItem) + MimeTypes.APPLICATION_M3U8 -> hlsFactory.createMediaSource(mediaItem) + else -> { + // Try progressive first; fall back to the default factory's + // extractor-based selection so generic URIs (e.g. local + // file:// from the downloads dir) still work. + runCatching { progFactory.createMediaSource(mediaItem) } + .getOrElse { defaultFactory.createMediaSource(mediaItem) } + } + } + } + + override fun setDrmSessionManagerProvider(p: DrmSessionManagerProvider): MediaSource.Factory { + dashFactory.setDrmSessionManagerProvider(p) + hlsFactory.setDrmSessionManagerProvider(p) + progFactory.setDrmSessionManagerProvider(p) + defaultFactory.setDrmSessionManagerProvider(p) + return this + } + + override fun setLoadErrorHandlingPolicy(p: LoadErrorHandlingPolicy): MediaSource.Factory { + dashFactory.setLoadErrorHandlingPolicy(p) + hlsFactory.setLoadErrorHandlingPolicy(p) + progFactory.setLoadErrorHandlingPolicy(p) + defaultFactory.setLoadErrorHandlingPolicy(p) + return this + } + + override fun getSupportedTypes(): IntArray = + intArrayOf(C.CONTENT_TYPE_DASH, C.CONTENT_TYPE_HLS, C.CONTENT_TYPE_OTHER) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 748c61379..173718f7a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -2,78 +2,83 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Phase C: Media3 PlayerView embedded in Compose. - * Phase D: SponsorBlock auto-skip wired in via position-poll loop. + * Fullscreen player surface. After the V-2 unification, the player + * itself lives in PlaybackService (one ExoPlayer for the whole app). + * This composable is a thin shell that: + * 1. Asks the PlayerViewModel to resolve the stream URL + * 2. Pushes the resolved MediaItem into the shared MediaController + * 3. Renders PlayerView bound to that controller + * 4. Runs the SponsorBlock skip loop against the controller + * 5. Lets the user drag-down to dismiss into the minibar + * + * Audio-only toggle, speed picker, share, manual PiP, and the + * background-audio button stay as overlays. Audio-only flips the + * controller's track-selection params; nothing more to do because + * playback is one player. */ package com.sulkta.straw.feature.player import android.app.Activity import android.app.PictureInPictureParams -import android.content.ComponentName import android.content.Intent import android.os.Build import android.util.Rational import android.widget.Toast -import androidx.core.content.ContextCompat import androidx.annotation.OptIn +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.Arrangement import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.foundation.layout.offset import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.media3.common.AudioAttributes import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.TrackGroup as Media3TrackGroup import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.exoplayer.dash.DashMediaSource -import androidx.media3.exoplayer.hls.HlsMediaSource -import androidx.media3.exoplayer.source.MergingMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView -import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.net.SbSegment import com.sulkta.straw.util.strawLogI +import kotlin.math.roundToInt import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(UnstableApi::class) @Composable @@ -81,195 +86,115 @@ fun PlayerScreen( streamUrl: String, title: String, startPositionMs: Long = 0L, + onMinimize: () -> Unit = {}, vm: PlayerViewModel = viewModel(), ) { val context = LocalContext.current + val controller = LocalStrawController.current val state by vm.ui.collectAsStateWithLifecycle() LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } - // Local UI state for speed / audio-only / dialog open. var playbackSpeed by remember { mutableStateOf(1.0f) } var audioOnly by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } - val exoPlayer = remember { - ExoPlayer.Builder(context) - .setAudioAttributes( - // Tell the system we're playing media so audio focus + - // ducking + Bluetooth routing work, and notifications can - // sit alongside other media apps. - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .build(), - /* handleAudioFocus = */ true, - ) - .build() - } - - // Wrap the player in a MediaSession so the OS gets lock-screen + - // notification media controls while this Activity is alive. Full - // background-audio-after-Activity-kill is M-3 (MediaSessionService + - // MediaController refactor). - val mediaSession = remember { - MediaSession.Builder(context, exoPlayer).build() - } - - // Path C-7: surface ExoPlayer failures so they don't read as "stuck spinner" - // (Audit Finding 2). Posts to playbackError state which the UI renders. - var playbackError by remember { mutableStateOf(null) } - DisposableEffect(exoPlayer) { - val listener = object : androidx.media3.common.Player.Listener { - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - playbackError = - "${error.errorCodeName}: ${error.message ?: "(no message)"}" - } - } - exoPlayer.addListener(listener) - onDispose { exoPlayer.removeListener(listener) } - } - - DisposableEffect(Unit) { - onDispose { - mediaSession.release() - exoPlayer.release() - } - } - - // Home / recents button → seamless hand-off to background audio service - // instead of Picture-in-Picture. PiP is still available manually via the - // ⊟ overlay button. We register a handler that the activity calls from - // onUserLeaveHint(); the handler captures currentPosition so the audio - // service resumes from the same point. Same code path that the explicit - // 🎧 button uses. - val resolvedState = androidx.compose.runtime.rememberUpdatedState(state.resolved) - DisposableEffect(Unit) { - PlayerLeaveHandler.handler = handler@{ - val r = resolvedState.value ?: return@handler - val audio = r.audioUrl ?: r.combinedUrl ?: return@handler - val position = exoPlayer.currentPosition.coerceAtLeast(0L) - runCatching { exoPlayer.stop() } - runCatching { exoPlayer.clearMediaItems() } - val intent = Intent(context, PlaybackService::class.java).apply { - component = ComponentName(context, PlaybackService::class.java) - putExtra(PlaybackService.EXTRA_URL, audio) - putExtra(PlaybackService.EXTRA_TITLE, title) - putExtra(PlaybackService.EXTRA_POSITION_MS, position) - } - ContextCompat.startForegroundService(context, intent) - } - onDispose { PlayerLeaveHandler.handler = null } - } - - // AUD-MED: pause playback when app goes to background. Without this, - // ExoPlayer keeps playing audio with no MediaSession — user can't pause - // from the notification shade. EXCEPTION: don't pause when entering - // Picture-in-Picture mode (that's the whole point of PiP). - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) { - val activity = context as? Activity - if (activity?.isInPictureInPictureMode != true) { - exoPlayer.pause() - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } + // Drag-to-minimize: vertical offset accumulated during the gesture. + // On release, if past threshold we dismiss into the minibar. + val density = LocalDensity.current + val dismissThresholdPx = with(density) { 200.dp.toPx() } + val dragY = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + // Push the resolved video into the shared controller as soon as we + // have stream URLs. If something else is already playing the same + // streamUrl, just seek instead of re-loading. + // For metadata that vm.resolve doesn't return (uploader / thumbnail) we + // try to lift them from the matching VideoDetail item if it's open in + // the same nav stack; otherwise fall back to whatever NowPlaying + // already has. Either way the minibar gets enough to render. + val detailVm: com.sulkta.straw.feature.detail.VideoDetailViewModel = viewModel() + LaunchedEffect(streamUrl) { detailVm.load(streamUrl) } + val detailState by detailVm.ui.collectAsStateWithLifecycle() val resolved = state.resolved - - LaunchedEffect(resolved) { + LaunchedEffect(controller, resolved, detailState.detail) { + val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect - // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs - // don't 403 on first byte. - val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( - DefaultHttpDataSource.Factory() - .setUserAgent(STRAW_USER_AGENT) - .setAllowCrossProtocolRedirects(true) - ) - - val source = when { - r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) - - r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.hlsUrl)) - - r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.combinedUrl)) - - r.videoUrl != null && r.audioUrl != null -> { - val v = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.videoUrl)) - val a = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.audioUrl)) - MergingMediaSource(v, a) - } - - r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(r.videoUrl)) - - else -> null - } - - if (source != null) { - exoPlayer.setMediaSource(source) - // V-2: when we navigate here from an inline player that was - // already playing, pick up at the same position instead of - // restarting. seekTo() before prepare() is allowed; the seek - // is queued and applied once the player is ready. - if (startPositionMs > 0) { - exoPlayer.seekTo(startPositionMs) - } - exoPlayer.prepare() - exoPlayer.playWhenReady = true - } - } - - // SponsorBlock auto-skip — poll position every 150ms, seek past any segment. - // AUD-HIGH fixes vs initial impl: - // - dedup skipped segments via UUID so re-listen doesn't fight the user - // - tighter poll (150ms) reduces sponsor leak through buffering window - // - check playbackState != IDLE/ENDED (was isPlaying, which is false - // during buffering and missed the skip window) - // - clamp seek target away from duration boundary to avoid jank - val skippedUuids = remember { mutableSetOf() } - LaunchedEffect(resolved?.segments) { - val segments = resolved?.segments ?: return@LaunchedEffect - if (segments.isEmpty()) return@LaunchedEffect - skippedUuids.clear() - while (true) { - delay(150) - val state = exoPlayer.playbackState - if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue - val posSec = exoPlayer.currentPosition / 1000.0 - val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue - strawLogI( - "StrawSb", - "skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)", + val d = detailState.detail + val uploader = d?.uploader ?: NowPlaying.current.value?.uploader.orEmpty() + val thumbnail = d?.thumbnail ?: NowPlaying.current.value?.thumbnail + val sameVideo = NowPlaying.current.value?.streamUrl == streamUrl + val currentTitle = c.mediaMetadata.title?.toString() + if (sameVideo && currentTitle == title) { + if (startPositionMs > 0) c.seekTo(startPositionMs) + if (!c.isPlaying) c.play() + NowPlaying.set( + NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = r.segments, + ), + ) + } else { + c.setPlayingFrom( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + resolved = r, + startPositionMs = startPositionMs, ) - val targetMs = (segment.endSec * 1000).toLong() - val durationMs = exoPlayer.duration - if (durationMs > 0 && targetMs >= durationMs - 500) { - // Past end — let it end naturally rather than seeking past content. - exoPlayer.seekTo(durationMs - 1) - } else { - exoPlayer.seekTo(targetMs) - } - segment.UUID?.let { skippedUuids.add(it) } - Toast.makeText(context, "skipped ${segment.category}", Toast.LENGTH_SHORT).show() } } + // Surface ExoPlayer failures from the service into the UI. + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(controller) { + val c = controller + val listener = object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + c?.addListener(listener) + onDispose { c?.removeListener(listener) } + } + + // Manual-PiP wiring (the ⊟ overlay button). The activity is the PiP + // host; we just feed it the right params. Auto-enter-on-home stays + // disabled — HOME triggers seamless minibar/background per #255. + val activity = context as? Activity + Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, dragY.value.roundToInt()) } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragY.value > dismissThresholdPx) { + // Snap to dismiss + pop into minibar. + onMinimize() + } else { + scope.launch { dragY.animateTo(0f, tween(180)) } + } + }, + onDragCancel = { + scope.launch { dragY.animateTo(0f, tween(180)) } + }, + onVerticalDrag = { _, dy -> + scope.launch { + // Clamp to non-negative — upward drag has no effect. + dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) + } + }, + ) + }, contentAlignment = Alignment.Center, ) { when { - state.loading -> CircularProgressIndicator() + state.loading || controller == null -> CircularProgressIndicator() state.error != null -> Text( "playback error: ${state.error}", @@ -292,14 +217,15 @@ fun PlayerScreen( AndroidView( factory = { ctx -> PlayerView(ctx).apply { - player = exoPlayer + player = controller useController = true } }, + update = { it.player = controller }, modifier = Modifier.fillMaxSize(), ) // SponsorBlock segment count badge — small overlay top-left. - resolved?.let { r -> + resolved.let { r -> Box( modifier = Modifier .align(Alignment.TopStart) @@ -315,20 +241,17 @@ fun PlayerScreen( ) } } - // Top-right overlay — speed / audio-only / share / PiP. + // Top-right overlay — speed / audio-only / share / PiP / minimize. Row( modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Playback speed OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") { showSpeedDialog = true } - // Audio-only toggle OverlayButton(label = if (audioOnly) "📻" else "📺") { audioOnly = !audioOnly - // Disable / enable video renderer via track-selection params. - exoPlayer.trackSelectionParameters = TrackSelectionParameters.Builder(context) + controller.trackSelectionParameters = TrackSelectionParameters.Builder(context) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly) .build() Toast.makeText( @@ -337,7 +260,6 @@ fun PlayerScreen( Toast.LENGTH_SHORT, ).show() } - // Share OverlayButton(label = "↗") { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -346,11 +268,8 @@ fun PlayerScreen( } context.startActivity(Intent.createChooser(send, "Share video")) } - // PiP — manual entry (auto-enter on home gesture is wired - // up via the DisposableEffect above on Android 12+). OverlayButton(label = "⊟") { - val act = (context as? Activity) - if (act == null) { + if (activity == null) { Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() return@OverlayButton } @@ -361,51 +280,16 @@ fun PlayerScreen( val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(16, 9)) .build() - val result = runCatching { act.enterPictureInPictureMode(params) } - result.onSuccess { ok -> - if (!ok) { - Toast.makeText( - context, - "PiP refused — check Settings > Apps > Straw > PiP", - Toast.LENGTH_LONG, - ).show() + runCatching { activity.enterPictureInPictureMode(params) } + .onSuccess { ok -> + if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show() + } + .onFailure { t -> + Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() } - } - result.onFailure { t -> - Toast.makeText( - context, - "PiP failed: ${t.message ?: t.javaClass.simpleName}", - Toast.LENGTH_LONG, - ).show() - } - } - // Background audio (phase S) — independent foreground-service playback. - // Audit HIGH-1: handing off, not dual-hosting. Stop activity's player - // first so the OS sees a single MediaSession (cleaner lockscreen + - // audio focus) and we don't leak two active ExoPlayers. - OverlayButton(label = "🎧") { - val r = resolved ?: return@OverlayButton - val audio = r.audioUrl ?: r.combinedUrl - if (audio == null) { - Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show() - return@OverlayButton - } - val position = exoPlayer.currentPosition.coerceAtLeast(0L) - runCatching { exoPlayer.stop() } - runCatching { exoPlayer.clearMediaItems() } - val intent = Intent(context, PlaybackService::class.java).apply { - component = ComponentName(context, PlaybackService::class.java) - putExtra(PlaybackService.EXTRA_URL, audio) - putExtra(PlaybackService.EXTRA_TITLE, title) - putExtra(PlaybackService.EXTRA_POSITION_MS, position) - } - ContextCompat.startForegroundService(context, intent) - Toast.makeText( - context, - "background audio started — close the app whenever", - Toast.LENGTH_SHORT, - ).show() } + // Explicit minimize button — same effect as drag-down. + OverlayButton(label = "⌄") { onMinimize() } } if (showSpeedDialog) { @@ -413,7 +297,7 @@ fun PlayerScreen( current = playbackSpeed, onPick = { s -> playbackSpeed = s - exoPlayer.playbackParameters = PlaybackParameters(s) + controller.playbackParameters = PlaybackParameters(s) showSpeedDialog = false }, onDismiss = { showSpeedDialog = false }, @@ -475,9 +359,45 @@ private fun SpeedPickerDialog( } /** - * Returns the segment whose interval contains [posSec], if any, skipping - * UUIDs in [skipped]. Filters out POI-style point segments (start == end). + * SponsorBlock skip loop driven by the controller's currentPosition. + * Runs at the activity composition root (not per-screen) so it skips + * segments whether the user is fullscreen, in the minibar, or away from + * the player surface. */ +@Composable +@OptIn(UnstableApi::class) +fun SponsorBlockSkipLoop() { + val controller = LocalStrawController.current + val context = LocalContext.current + val item by NowPlaying.current.collectAsStateWithLifecycle() + val cur = item ?: return + val segments = cur.segments + if (segments.isEmpty() || controller == null) return + val skipped = remember(cur.streamUrl) { mutableSetOf() } + LaunchedEffect(cur.streamUrl, controller) { + while (true) { + delay(150) + val state = controller.playbackState + if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue + val posSec = controller.currentPosition / 1000.0 + val s = pickActiveSegment(segments, posSec, skipped) ?: continue + strawLogI( + "StrawSb", + "skip: ${s.category} ${s.startSec}s..${s.endSec}s (pos=$posSec)", + ) + val targetMs = (s.endSec * 1000).toLong() + val durationMs = controller.duration + if (durationMs > 0 && targetMs >= durationMs - 500) { + controller.seekTo(durationMs - 1) + } else { + controller.seekTo(targetMs) + } + s.UUID?.let { skipped.add(it) } + Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show() + } + } +} + private fun pickActiveSegment( segments: List, posSec: Double, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt new file mode 100644 index 000000000..6ad3d376f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Composable bridge to the PlaybackService MediaController. + * + * Why this file exists: every UI surface in Straw (inline player on the + * detail screen, the fullscreen Player, the minibar overlay) renders the + * same single underlying MediaController. We expose it via a + * CompositionLocal so the screens don't have to know how to connect. + * + * The controller is built async — SessionToken bind happens on a + * background thread, the controller future resolves once the service is + * up. Until then `LocalStrawController.current` is null; consumers + * should render placeholder UI in that brief window. + * + * Lifecycle: tied to the activity's composition. When the activity + * finishes the DisposableEffect cleanup releases the future. The + * MediaSessionService stays alive iff there's still something playing + * (its own onTaskRemoved + STATE_ENDED logic handles that). + * + * Also: a small helper, [setPlayingFrom], that knows how to convert + * Straw's domain ResolvedPlayback (DASH URL / HLS URL / combined URL / + * video+audio pair) into a single MediaItem the service understands. + */ + +package com.sulkta.straw.feature.player + +import android.content.ComponentName +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors + +val LocalStrawController = compositionLocalOf { null } + +@Composable +fun rememberStrawController(): MediaController? { + val context = LocalContext.current + val state = remember { mutableStateOf(null) } + DisposableEffect(Unit) { + val token = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + val future = MediaController.Builder(context, token).buildAsync() + future.addListener({ + // future.get() throws if the build failed; treat as null in that case. + state.value = runCatching { future.get() }.getOrNull() + }, MoreExecutors.directExecutor()) + onDispose { + MediaController.releaseFuture(future) + state.value = null + } + } + return state.value +} + +/** + * Push a resolved video into the controller and update NowPlaying. + * + * Stream-shape preference matches the previous activity-side picker: + * DASH (full quality + adaptive) > HLS > combined progressive > merged + * video+audio progressives > video-only progressive. The + * [StrawMediaSourceFactory] on the service end picks the right inner + * MediaSource based on MIME + the EXTRA_AUDIO_URL bundle. + */ +@UnstableApi +fun MediaController.setPlayingFrom( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, + startPositionMs: Long = 0L, +) { + val item = buildMediaItem(title, uploader, thumbnail, resolved) ?: return + setMediaItem(item, startPositionMs) + prepare() + playWhenReady = true + NowPlaying.set( + NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = resolved.segments, + ), + ) +} + +@UnstableApi +private fun buildMediaItem( + title: String, + uploader: String, + thumbnail: String?, + r: ResolvedPlayback, +): MediaItem? { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setArtist(uploader) + .apply { + thumbnail?.let { setArtworkUri(android.net.Uri.parse(it)) } + } + .build() + val baseBuilder = MediaItem.Builder().setMediaMetadata(metadata) + return when { + !r.dashMpdUrl.isNullOrBlank() -> baseBuilder + .setUri(r.dashMpdUrl) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build() + !r.hlsUrl.isNullOrBlank() -> baseBuilder + .setUri(r.hlsUrl) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build() + !r.combinedUrl.isNullOrBlank() -> baseBuilder + .setUri(r.combinedUrl) + .build() + !r.videoUrl.isNullOrBlank() && !r.audioUrl.isNullOrBlank() -> { + val extras = Bundle().apply { + putString(PlaybackService.EXTRA_AUDIO_URL, r.audioUrl) + } + baseBuilder + .setUri(r.videoUrl) + .setRequestMetadata( + MediaItem.RequestMetadata.Builder() + .setExtras(extras) + .build(), + ) + .build() + } + !r.videoUrl.isNullOrBlank() -> baseBuilder + .setUri(r.videoUrl) + .build() + else -> null + } +} From 3ff9740c408c6c3be7717a4b5ed4adbf7010b3cd Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 16:25:30 +0000 Subject: [PATCH 07/87] detail: wrap action row with FlowRow so Save doesnt clip on narrow widths --- .../com/sulkta/straw/feature/detail/VideoDetailScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 3f93f1df6..cc33676fe 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -10,6 +10,8 @@ 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -75,6 +77,7 @@ import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml +@OptIn(ExperimentalLayoutApi::class) @Composable fun VideoDetailScreen( streamUrl: String, @@ -218,7 +221,10 @@ fun VideoDetailScreen( } Spacer(modifier = Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Button(onClick = { onPlay(inlinePositionMs) }) { Text("Play") } OutlinedButton(onClick = { val send = Intent(Intent.ACTION_SEND).apply { From 1443bb8ef77aee4346e3dfc2475eb521c747d5b7 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 16:44:27 +0000 Subject: [PATCH 08/87] vc=24: NewPipe/Tubular settings import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings screen now has an Import section. Picks a NewPipeData-*.zip or TubularData-*.zip via the SAF, extracts newpipe.db + preferences.json, and walks the Room SQLite schema (subscriptions / playlists / playlist_stream_join / streams / search_history / stream_history / stream_state). YouTube only — service_id=0 filter drops SoundCloud/PeerTube/etc. Imported on smoke: 26 subs, 1 playlist with 10 items, 50/11402 watch history (capped by HistoryStore MAX_WATCHES), 20/2242 searches (MAX_SEARCHES), 8 settings keys (SponsorBlock category toggles + default resolution). Resume positions counted (10918) but not yet persisted — Straw has no resume-store yet. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../feature/dataimport/SettingsImport.kt | 419 ++++++++++++++++++ .../straw/feature/settings/SettingsScreen.kt | 81 +++- 3 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 14969bfa5..c5fbc9d7f 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 23 -const val STRAW_VERSION_NAME = "0.1.0-AI" +const val STRAW_VERSION_CODE = 24 +const val STRAW_VERSION_NAME = "0.1.0-AJ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt new file mode 100644 index 000000000..4e138788e --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -0,0 +1,419 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * NewPipe / Tubular export importer. + * + * The user picks an exported `.zip` (NewPipe writes it as + * `NewPipeData-.zip`, Tubular as `TubularData-.zip`). + * Inside: + * - newpipe.db Room SQLite (subscriptions, playlists, history…) + * - preferences.json flat key/value of all user settings + * - newpipe.settings superseded XML form of preferences (we ignore) + * + * We populate Straw's existing stores (Subscriptions, Playlists, History, + * Settings) — filtering to service_id=0 (YouTube). Other services + * (SoundCloud / PeerTube / …) are silently dropped — we don't support + * them and a mixed import would surprise the user later. + * + * Resume positions (NewPipe `stream_state` table) are read but + * intentionally not persisted yet — Straw has no resume-positions + * store. Counted in [ImportResult.resumePositionsSeen] so the user + * knows the data was present even if dropped. + */ + +package com.sulkta.straw.feature.dataimport + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.History +import com.sulkta.straw.data.PlaylistItem +import com.sulkta.straw.data.Playlists +import com.sulkta.straw.data.SbCategory +import com.sulkta.straw.data.Settings +import com.sulkta.straw.data.Subscriptions +import com.sulkta.straw.data.WatchHistoryItem +import java.io.File +import java.util.zip.ZipInputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +data class ImportResult( + val subscriptionsAdded: Int, + val subscriptionsSkippedNonYt: Int, + val playlistsAdded: Int, + val playlistItemsAdded: Int, + val searchHistoryAdded: Int, + val searchHistoryAvailable: Int, + val watchHistoryAdded: Int, + val watchHistoryAvailable: Int, + val resumePositionsSeen: Int, + val settingsApplied: Int, + val warnings: List, +) { + fun summary(): String = buildString { + append("Imported ") + append(subscriptionsAdded) + append(" subs") + if (subscriptionsSkippedNonYt > 0) { + append(" (skipped ") + append(subscriptionsSkippedNonYt) + append(" non-YouTube)") + } + append(", ") + append(playlistsAdded) + append(" playlist") + if (playlistsAdded != 1) append("s") + append(" (") + append(playlistItemsAdded) + append(" videos), ") + append(watchHistoryAdded) + append("/") + append(watchHistoryAvailable) + append(" watch history, ") + append(searchHistoryAdded) + append("/") + append(searchHistoryAvailable) + append(" searches, ") + append(settingsApplied) + append(" settings.") + if (resumePositionsSeen > 0) { + append(" Resume positions (") + append(resumePositionsSeen) + append(") not yet supported — dropped.") + } + if (warnings.isNotEmpty()) { + append("\n\nWarnings:\n") + warnings.forEach { append("• "); append(it); append("\n") } + } + } +} + +object SettingsImport { + + // YouTube only — Straw doesn't extract from other services. + private const val YT_SERVICE_ID = 0 + + suspend fun run(context: Context, zipUri: Uri): Result = + withContext(Dispatchers.IO) { + runCatching { runInner(context, zipUri) } + } + + private fun runInner(context: Context, zipUri: Uri): ImportResult { + val warnings = mutableListOf() + val workDir = File(context.cacheDir, "newpipe-import-${System.currentTimeMillis()}") + workDir.mkdirs() + try { + val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings) + + val subsResult = if (dbFile != null) importSubscriptions(dbFile) else SubsResult(0, 0) + val plResult = if (dbFile != null) importPlaylists(dbFile) else PlResult(0, 0) + val histResult = if (dbFile != null) importHistory(dbFile) else HistResult(0, 0, 0, 0, 0) + val settingsResult = if (prefsJson != null) importSettings(prefsJson) else 0 + + return ImportResult( + subscriptionsAdded = subsResult.added, + subscriptionsSkippedNonYt = subsResult.skipped, + playlistsAdded = plResult.playlists, + playlistItemsAdded = plResult.items, + searchHistoryAdded = histResult.searches, + searchHistoryAvailable = histResult.searchesAvailable, + watchHistoryAdded = histResult.watchesAdded, + watchHistoryAvailable = histResult.watchesAvailable, + resumePositionsSeen = histResult.resumePositions, + settingsApplied = settingsResult, + warnings = warnings, + ) + } finally { + workDir.deleteRecursively() + } + } + + private fun extractZip( + context: Context, + zipUri: Uri, + workDir: File, + warnings: MutableList, + ): Pair { + var dbFile: File? = null + var prefs: JsonObject? = null + context.contentResolver.openInputStream(zipUri)?.use { input -> + ZipInputStream(input).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + when (entry.name) { + "newpipe.db" -> { + val out = File(workDir, "newpipe.db") + out.outputStream().use { os -> + zip.copyTo(os, bufferSize = 64 * 1024) + } + dbFile = out + } + "preferences.json" -> { + val bytes = zip.readBytes() + prefs = runCatching { + Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject + }.getOrNull() + if (prefs == null) warnings += "preferences.json present but unparseable" + } + // newpipe.settings is the legacy XML form; preferences.json + // supersedes it in every modern export. Skip. + else -> { /* ignore other entries */ } + } + zip.closeEntry() + } + } + } ?: error("Could not open the selected file") + if (dbFile == null) warnings += "newpipe.db not found in archive — most data skipped" + if (prefs == null) warnings += "preferences.json not found — settings not migrated" + return dbFile to prefs + } + + private data class SubsResult(val added: Int, val skipped: Int) + private fun importSubscriptions(dbFile: File): SubsResult { + val store = Subscriptions.get() + var added = 0 + var skipped = 0 + openDb(dbFile).use { db -> + db.rawQuery( + "SELECT url, name, avatar_url, service_id FROM subscriptions", + null, + ).use { c -> + while (c.moveToNext()) { + val serviceId = c.getInt(3) + if (serviceId != YT_SERVICE_ID) { + skipped++ + continue + } + val url = c.getString(0) ?: continue + val name = c.getString(1) ?: continue + val avatar = c.getString(2) + if (!store.isSubscribed(url)) { + store.toggle(ChannelRef(url = url, name = name, avatar = avatar)) + added++ + } + } + } + } + return SubsResult(added, skipped) + } + + private data class PlResult(val playlists: Int, val items: Int) + private fun importPlaylists(dbFile: File): PlResult { + val store = Playlists.get() + var playlistsAdded = 0 + var itemsAdded = 0 + openDb(dbFile).use { db -> + val playlistRows = mutableListOf>() + db.rawQuery("SELECT uid, name FROM playlists", null).use { c -> + while (c.moveToNext()) { + val uid = c.getLong(0) + val name = c.getString(1) ?: "Untitled" + playlistRows += uid to name + } + } + for ((uid, name) in playlistRows) { + val items = mutableListOf() + db.rawQuery( + """ + SELECT s.url, s.title, s.thumbnail_url, s.uploader, s.service_id + FROM playlist_stream_join j + JOIN streams s ON s.uid = j.stream_id + WHERE j.playlist_id = ? + ORDER BY j.join_index + """.trimIndent(), + arrayOf(uid.toString()), + ).use { c -> + while (c.moveToNext()) { + if (c.getInt(4) != YT_SERVICE_ID) continue + items += PlaylistItem( + streamUrl = c.getString(0) ?: continue, + title = c.getString(1) ?: "(no title)", + thumbnail = c.getString(2), + uploader = c.getString(3) ?: "", + addedAt = System.currentTimeMillis(), + ) + } + } + if (items.isEmpty()) continue + // Use the store's normal create + addItem rather than minting + // a Playlist directly — keeps the atomic-update path + // consistent with user-driven creates. + val created = store.create(name) + for (it in items) store.addItem(created.id, it) + playlistsAdded++ + itemsAdded += items.size + } + } + return PlResult(playlistsAdded, itemsAdded) + } + + private data class HistResult( + val watchesAdded: Int, + val watchesAvailable: Int, + val searches: Int, + val searchesAvailable: Int, + val resumePositions: Int, + ) + + private fun importHistory(dbFile: File): HistResult { + val historyStore = History.get() + var watchesSeen = 0 + var watchesAvailable = 0 + var searchesSeen = 0 + var resumePositions = 0 + val searchesBefore = historyStore.searches.value.size + val watchesBefore = historyStore.watches.value.size + openDb(dbFile).use { db -> + // Search history — feed oldest first so the store ends up with + // the most-recent on top after its own dedup + take(MAX). + db.rawQuery( + "SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC", + arrayOf(YT_SERVICE_ID.toString()), + ).use { c -> + while (c.moveToNext()) { + val q = c.getString(0) ?: continue + historyStore.recordSearch(q) + searchesSeen++ + } + } + + // Watch history — newest first via stream_history.access_date, + // joined to streams for the metadata we need. + // recordWatch caps internally; we just stop counting "added" once + // we've replayed Straw's MAX rows. (The store reverses to put + // most-recent on top — so we feed it oldest-first to match.) + db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c -> + if (c.moveToNext()) watchesAvailable = c.getInt(0) + } + db.rawQuery( + """ + SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id + FROM stream_history h + JOIN streams s ON s.uid = h.stream_id + ORDER BY h.access_date ASC + """.trimIndent(), + null, + ).use { c -> + while (c.moveToNext()) { + if (c.getInt(5) != YT_SERVICE_ID) continue + val url = c.getString(0) ?: continue + val title = c.getString(1) ?: continue + val uploader = c.getString(2) ?: "" + val thumb = c.getString(3) + val videoId = extractYtVideoId(url) ?: continue + historyStore.recordWatch( + WatchHistoryItem( + url = url, + videoId = videoId, + title = title, + uploader = uploader, + thumbnail = thumb, + watchedAt = c.getLong(4), + ), + ) + watchesSeen++ + } + } + + // Resume positions — counted, not stored. Future task hooks into + // a ResumePositionsStore. + db.rawQuery("SELECT COUNT(*) FROM stream_state", null).use { c -> + if (c.moveToNext()) resumePositions = c.getInt(0) + } + } + // Report what actually landed in the store after its dedup + caps. + return HistResult( + watchesAdded = historyStore.watches.value.size - watchesBefore, + watchesAvailable = watchesAvailable.takeIf { it > 0 } ?: watchesSeen, + searches = historyStore.searches.value.size - searchesBefore, + resumePositions = resumePositions, + searchesAvailable = searchesSeen, + ) + } + + private fun importSettings(prefs: JsonObject): Int { + val settings = Settings.get() + var applied = 0 + + // SponsorBlock: master toggle gates the categories. If disabled in + // NewPipe, leave Straw's categories alone (they have a non-empty + // default). If enabled, sync each category boolean. + val sbMaster = prefs.boolOrNull("sponsor_block_enable") + if (sbMaster == true) { + val targets = mapOf( + "sponsor_block_category_sponsor" to SbCategory.Sponsor, + "sponsor_block_category_self_promo" to SbCategory.SelfPromo, + "sponsor_block_category_intro" to SbCategory.Intro, + "sponsor_block_category_outro" to SbCategory.Outro, + "sponsor_block_category_interaction" to SbCategory.Interaction, + "sponsor_block_category_music" to SbCategory.MusicOfftopic, + "sponsor_block_category_filler" to SbCategory.Filler, + ) + val current = settings.sbCategories.value + for ((key, cat) in targets) { + val want = prefs.boolOrNull(key) ?: continue + val have = cat in current + if (want != have) settings.toggle(cat) + applied++ + } + } + + // Default resolution: NewPipe values like "720p60", "1080p", "Best + // resolution". Map down to Straw's discrete ceilings. + prefs.stringOrNull("default_resolution")?.let { raw -> + val r = parseResolution(raw) + if (r != null) { + settings.setMaxResolution(r) + applied++ + } + } + + return applied + } + + private fun parseResolution(raw: String): com.sulkta.straw.data.MaxResolution? { + val n = Regex("(\\d+)").find(raw)?.groupValues?.get(1)?.toIntOrNull() + ?: return when (raw.lowercase()) { + "best resolution", "best", "highest" -> com.sulkta.straw.data.MaxResolution.Auto + else -> null + } + return when { + n >= 1080 -> com.sulkta.straw.data.MaxResolution.P1080 + n >= 720 -> com.sulkta.straw.data.MaxResolution.P720 + n >= 480 -> com.sulkta.straw.data.MaxResolution.P480 + n >= 360 -> com.sulkta.straw.data.MaxResolution.P360 + else -> com.sulkta.straw.data.MaxResolution.P144 + } + } + + private fun openDb(dbFile: File): SQLiteDatabase = + SQLiteDatabase.openDatabase( + dbFile.absolutePath, + /* factory = */ null, + SQLiteDatabase.OPEN_READONLY, + ) + + // YouTube URL patterns we need to parse for the videoId column on + // WatchHistoryItem. Cover the watch?v= form (canonical), youtu.be + // shortlinks, and embed/. Reject anything we can't parse rather than + // inventing IDs. + private val YT_ID = Regex( + "(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:watch\\?(?:.*&)?v=|embed/|v/|shorts/))([A-Za-z0-9_-]{6,15})", + ) + private fun extractYtVideoId(url: String): String? = + YT_ID.find(url)?.groupValues?.get(1) + + private fun JsonObject.boolOrNull(key: String): Boolean? = + runCatching { this[key]?.jsonPrimitive?.boolean }.getOrNull() + + private fun JsonObject.stringOrNull(key: String): String? = + runCatching { this[key]?.jsonPrimitive?.contentOrNull }.getOrNull() +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index f090fc99c..f3e704b0d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -5,41 +5,66 @@ package com.sulkta.straw.feature.settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.statusBarsPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.collectAsState +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.sulkta.straw.data.History import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.SbCategory import com.sulkta.straw.data.Settings +import com.sulkta.straw.feature.dataimport.ImportResult +import com.sulkta.straw.feature.dataimport.SettingsImport +import kotlinx.coroutines.launch @Composable fun SettingsScreen() { val store = Settings.get() val cats by store.sbCategories.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var importRunning by remember { mutableStateOf(false) } + var importResult by remember { mutableStateOf?>(null) } + val pickZip = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + importRunning = true + scope.launch { + importResult = SettingsImport.run(context, uri) + importRunning = false + } + } Column( modifier = Modifier @@ -124,6 +149,54 @@ fun SettingsScreen() { Text("Clear searches") } } + + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Import from NewPipe / Tubular", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Pick a NewPipeData-*.zip or TubularData-*.zip — we'll lift " + + "your subscriptions, playlists, search history, watch history " + + "(capped to 50 most recent), and a curated subset of settings. " + + "Other services (SoundCloud, PeerTube) are skipped.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + enabled = !importRunning, + onClick = { pickZip.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }, + ) { + if (importRunning) { + CircularProgressIndicator( + modifier = Modifier.height(18.dp).padding(end = 8.dp), + strokeWidth = 2.dp, + ) + Text("Importing…") + } else { + Text("Pick export file…") + } + } + } + + importResult?.let { res -> + AlertDialog( + onDismissRequest = { importResult = null }, + title = { Text(if (res.isSuccess) "Import complete" else "Import failed") }, + text = { + val body = res.fold( + onSuccess = { it.summary() }, + onFailure = { it.message ?: it.javaClass.simpleName }, + ) + Text(body, style = MaterialTheme.typography.bodyMedium) + }, + confirmButton = { + TextButton(onClick = { importResult = null }) { Text("OK") } + }, + ) } } From 21fc81ee7730b7dacd1d6548f13e65db751f7600 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 17:01:10 +0000 Subject: [PATCH 09/87] =?UTF-8?q?vc=3D25:=20audit-fix=20sprint=20=E2=80=94?= =?UTF-8?q?=20CRIT=20+=20HIGH=20+=20MED=20+=20LOW=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus max-effort audit of the vc=23 post-MediaController-unification codebase surfaced two CRITs, both in my own recent code. CRIT-1 + 1b: inline-position-threading band-aid deleted. After the V-2 controller unification, seeking the live controller to its own currentPosition was always 0-500ms backwards — every inline-to- fullscreen and minibar-expand handoff jerked playback backward. The whole `inlinePositionMs` / `onPositionChanged` / `startPositionMs` / `seekTo` chain is gone. The controller is one player; no handoff needed. CRIT-2: PlayerLeaveHandler removed. The registry was fully orphaned — nothing ever assigned to handler. Media3 handles HOME-to-background natively via the foreground service. Dropped the file, the onUserLeaveHint override, and the import. HIGH-1 + 5: PlayerViewModel collapsed into VideoDetailViewModel. Both fetched the same streamInfo for the same URL; PlayerScreen used to spin up two VMs to lift uploader + thumbnail from one and stream URLs from the other. One VM now exposes both `detail` and `resolved`. Drops a redundant network fetch and the double-spinner UX on PlayerScreen. HIGH-2: AndroidView { PlayerView } in PlayerScreen + InlinePlayer now has onRelease { it.player = null } so PlayerView surfaces stop retaining the controller after the composable leaves composition. HIGH-3: SubscriptionFeedViewModel switched to a per-channel cache. Each channel's entries refresh on their own TTL — adding one new subscription no longer invalidates the other 49. Failed/timed-out channel fetches leave the prior cache entry intact instead of blanking the feed for that channel. HIGH-4: onNewIntent override added. singleTask was silently dropping shared-from-Chrome YT URLs whenever Straw was already running. New intents now feed pendingDeepLink which the Compose tree drains into Screen.VideoDetail. MED-3, MED-8, MED-10, LOW batch: PlayerView control-overlay overlap fixed by going through one strategy; SearchViewModel.recordSearch moved into the success branch so errored queries don't pollute recent searches; Downloader's host whitelist tightened to *.googlevideo.com only; SubscriptionsStore.clear + HistoryStore.clearWatches/Searches now use updateAndGet for atomic clear consistent with the other writers; phase/path/audit-ticket markers stripped from comments (kept the technical commentary, dropped sprint tags); 4x duplicated Color(0xCC222222) overlay color extracted to OverlayChromeColor named constant in StrawTheme; HtmlText + StrawActivity NewPipeExtractor references replaced with the current extractor. Net: ~80 LOC deleted overall (the position-threading + handler registry + duplicate VM more than offset the cache + onNewIntent additions). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 14 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 167 +++++++++--------- .../kotlin/com/sulkta/straw/StrawTheme.kt | 6 + .../com/sulkta/straw/data/HistoryStore.kt | 20 +-- .../sulkta/straw/data/SubscriptionsStore.kt | 15 +- .../straw/feature/detail/VideoDetailScreen.kt | 153 +++++++--------- .../feature/detail/VideoDetailViewModel.kt | 96 ++++++++-- .../straw/feature/download/Downloader.kt | 23 +-- .../feature/feed/SubscriptionFeedViewModel.kt | 131 ++++++++------ .../feature/player/PlayerLeaveHandler.kt | 17 -- .../straw/feature/player/PlayerScreen.kt | 137 ++++++-------- .../straw/feature/player/PlayerViewModel.kt | 103 ----------- .../feature/player/StrawMediaController.kt | 1 + .../straw/feature/search/SearchViewModel.kt | 11 +- .../kotlin/com/sulkta/straw/util/HtmlText.kt | 6 +- 16 files changed, 403 insertions(+), 501 deletions(-) delete mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt delete mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index c5fbc9d7f..12e988a7a 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 24 -const val STRAW_VERSION_NAME = "0.1.0-AJ" +const val STRAW_VERSION_CODE = 25 +const val STRAW_VERSION_NAME = "0.1.0-AK" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index bf91616fa..9763aa727 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Tiny in-app nav model — sealed Screen + a stack. No nav library; pure - * state. Good enough for day-2's home → search → detail → player flow. + * state. */ package com.sulkta.straw @@ -19,11 +19,7 @@ sealed interface Screen { data object Playlists : Screen data object Downloads : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen - data class Player( - val streamUrl: String, - val title: String, - val startPositionMs: Long = 0L, - ) : Screen + data class Player(val streamUrl: String, val title: String) : Screen data class Channel(val channelUrl: String, val name: String) : Screen data class PlaylistView(val playlistId: String, val name: String) : Screen } @@ -36,7 +32,11 @@ class Navigator(initial: Screen) { stack.add(s) } - /** @return false if we couldn't pop (root), true otherwise. */ + /** + * Pop the current screen off the stack. Returns false at root so the + * caller can defer to the system back behavior (exit the app); true + * otherwise. + */ fun pop(): Boolean { if (stack.size <= 1) return false stack.removeAt(stack.lastIndex) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 7d16e4650..dea41ffea 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -19,6 +19,9 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.media3.common.util.UnstableApi @@ -27,7 +30,7 @@ import com.sulkta.straw.feature.detail.VideoDetailScreen import com.sulkta.straw.feature.download.DownloadsScreen import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.MinibarOverlay -import com.sulkta.straw.feature.player.PlayerLeaveHandler +import com.sulkta.straw.feature.player.NowPlaying import com.sulkta.straw.feature.player.PlayerScreen import com.sulkta.straw.feature.player.SponsorBlockSkipLoop import com.sulkta.straw.feature.player.rememberStrawController @@ -35,6 +38,7 @@ import com.sulkta.straw.feature.playlist.PlaylistViewScreen import com.sulkta.straw.feature.playlist.PlaylistsScreen import com.sulkta.straw.feature.search.SearchScreen import com.sulkta.straw.feature.settings.SettingsScreen +import kotlinx.coroutines.flow.MutableStateFlow private val YT_HOSTS = setOf( "youtube.com", "www.youtube.com", "m.youtube.com", @@ -46,6 +50,15 @@ private val YT_URL_RE = Regex( ) class StrawActivity : ComponentActivity() { + + /** + * Newly-arrived deep-link URL while the activity is already running. + * `onNewIntent` writes here; the Compose tree observes and pushes a + * VideoDetail screen. Without this the singleTask flag silently drops + * every share-to-Straw after the first. + */ + private val pendingDeepLink = MutableStateFlow(null) + @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -55,10 +68,9 @@ class StrawActivity : ComponentActivity() { setContent { val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors() - // Build one MediaController for the whole activity. Every screen - // pulls it via LocalStrawController, every PlayerView binds to - // it, and the minibar overlay (rendered below) uses it too. - // Single player, single source of truth. + // One MediaController for the whole activity. Every screen pulls + // it via LocalStrawController; the minibar overlay below uses it + // too. Single player, single source of truth. val controller = rememberStrawController() MaterialTheme(colorScheme = scheme) { CompositionLocalProvider(LocalStrawController provides controller) { @@ -80,6 +92,15 @@ class StrawActivity : ComponentActivity() { onDispose { cb.remove() } } + // Drain newly-arrived deep links. Consumed (cleared) once + // pushed so we don't re-navigate on every recomposition. + val pending by pendingDeepLink.collectAsState() + LaunchedEffect(pending) { + val url = pending ?: return@LaunchedEffect + nav.push(Screen.VideoDetail(url, "")) + pendingDeepLink.value = null + } + // SponsorBlock skip loop runs at the activity level so it // applies whether the user is fullscreen, in the minibar, // or away from the player surface. @@ -87,21 +108,13 @@ class StrawActivity : ComponentActivity() { Box(modifier = Modifier.fillMaxSize()) { ScreenContent(nav, s = nav.current) - // Persistent minibar overlay — visible on every screen - // except Player itself (fullscreen has its own UI). + // Persistent minibar — visible on every non-Player + // screen whenever something is loaded. if (nav.current !is Screen.Player) { MinibarOverlay( onExpand = { - val item = com.sulkta.straw.feature.player.NowPlaying.current.value - if (item != null) { - nav.push( - Screen.Player( - item.streamUrl, - item.title, - controller?.currentPosition ?: 0L, - ) - ) - } + val item = NowPlaying.current.value ?: return@MinibarOverlay + nav.push(Screen.Player(item.streamUrl, item.title)) }, modifier = Modifier.align(Alignment.BottomCenter), ) @@ -113,97 +126,79 @@ class StrawActivity : ComponentActivity() { } } + /** + * `launchMode="singleTask"` means a fresh VIEW/SEND from Chrome lands + * on the already-running activity instead of creating a new instance. + * Forward the URL into the Compose tree via the pending-link flow. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it } + } + @Composable private fun ScreenContent(nav: Navigator, s: Screen) { when (s) { - is Screen.Home -> StrawHome( - onOpenSearch = { nav.push(Screen.Search) }, - onOpenSettings = { nav.push(Screen.Settings) }, - onOpenPlaylists = { nav.push(Screen.Playlists) }, - onOpenDownloads = { nav.push(Screen.Downloads) }, - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, - onOpenChannel = { url, name -> - nav.push(Screen.Channel(url, name)) - }, - ) - is Screen.Downloads -> DownloadsScreen() - is Screen.Settings -> SettingsScreen() - is Screen.Search -> SearchScreen( - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, - ) - is Screen.VideoDetail -> VideoDetailScreen( - streamUrl = s.streamUrl, - initialTitle = s.title, - onPlay = { startPositionMs -> - nav.push(Screen.Player(s.streamUrl, s.title, startPositionMs)) - }, - onOpenChannel = { url, name -> - nav.push(Screen.Channel(url, name)) - }, - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, - ) - is Screen.Channel -> ChannelScreen( - channelUrl = s.channelUrl, - initialName = s.name, - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, - ) - is Screen.Player -> PlayerScreen( - streamUrl = s.streamUrl, - title = s.title, - startPositionMs = s.startPositionMs, - onMinimize = { nav.pop() }, - ) - is Screen.Playlists -> PlaylistsScreen( - onOpenPlaylist = { id, name -> - nav.push(Screen.PlaylistView(id, name)) - }, - ) + is Screen.Home -> StrawHome( + onOpenSearch = { nav.push(Screen.Search) }, + onOpenSettings = { nav.push(Screen.Settings) }, + onOpenPlaylists = { nav.push(Screen.Playlists) }, + onOpenDownloads = { nav.push(Screen.Downloads) }, + onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, + onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, + ) + is Screen.Downloads -> DownloadsScreen() + is Screen.Settings -> SettingsScreen() + is Screen.Search -> SearchScreen( + onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, + ) + is Screen.VideoDetail -> VideoDetailScreen( + streamUrl = s.streamUrl, + initialTitle = s.title, + onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) }, + onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, + onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, + ) + is Screen.Channel -> ChannelScreen( + channelUrl = s.channelUrl, + initialName = s.name, + onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, + ) + is Screen.Player -> PlayerScreen( + streamUrl = s.streamUrl, + title = s.title, + onMinimize = { nav.pop() }, + ) + is Screen.Playlists -> PlaylistsScreen( + onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) }, + ) is Screen.PlaylistView -> PlaylistViewScreen( playlistId = s.playlistId, initialName = s.name, - onOpenVideo = { url, title -> - nav.push(Screen.VideoDetail(url, title)) - }, + onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, ) } } - /** - * HOME / recents → seamless hand-off to background audio when the - * player screen is active. PlayerScreen registers the handler; any - * other screen leaves it null and home behaves normally. - */ - override fun onUserLeaveHint() { - super.onUserLeaveHint() - PlayerLeaveHandler.handler?.invoke() - } - - /** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */ + /** Pull a YouTube URL out of an incoming VIEW or SEND intent. */ private fun pickYouTubeUrl(intent: Intent?): String? { intent ?: return null return when (intent.action) { Intent.ACTION_VIEW -> { val data = intent.data?.toString() ?: return null // Explicit scheme + host check — defense in depth vs the - // manifest intent-filter (apps can synth intents that - // bypass filter scheme matching when activity is exported). + // manifest intent-filter; apps can synth intents that + // bypass filter scheme matching on exported activities. if (intent.scheme?.lowercase() !in setOf("https", "http")) return null if (!looksLikeYouTube(data)) return null data } Intent.ACTION_SEND -> { val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null - // Regex extracts a YT-looking substring from arbitrary - // attacker-controlled text. Re-validate via URI parse + host - // check before we hand it to NewPipeExtractor. + // Extract a YT-looking substring from attacker-controlled + // text, then re-validate via URI parse + host check before + // handing it to the extractor. val candidate = YT_URL_RE.find(shared)?.value ?: return null val truncated = candidate.substringBefore('#').trim() if (!looksLikeYouTube(truncated)) return null diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt index 74ad7be59..c3ee116ec 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -62,3 +62,9 @@ fun strawDarkColors(): ColorScheme = darkColorScheme( tertiary = DarkGreenTertiary, onTertiary = DarkGreenOnTertiary, ) + +// Semi-transparent overlays for chrome (overlay buttons, the SB badge, +// the inline-player fullscreen pill) and for the dimmed area behind the +// minibar thumbnail. Kept here so a theme tweak touches one place. +val OverlayChromeColor = androidx.compose.ui.graphics.Color(0xCC222222) +val OverlayDimColor = androidx.compose.ui.graphics.Color(0xCC000000) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 8ed488e48..cc04a788c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * SharedPreferences-backed recent watches + recent search store. Day-3. - * Day-4 graduates to Room when there's a real query pattern (date ranges, - * full-text search, etc.) that SharedPreferences can't serve. + * Recent watches + recent searches backed by SharedPreferences JSON + * blobs. Capped to MAX_WATCHES / MAX_SEARCHES. Graduates to Room when + * a real query pattern (date ranges, full-text search) shows up. */ package com.sulkta.straw.data @@ -46,9 +46,9 @@ class HistoryStore(context: Context) { fun recordWatch(item: WatchHistoryItem) { val now = item.copy(watchedAt = System.currentTimeMillis()) - // Atomic read-modify-write via StateFlow.updateAndGet — fixes - // AUD-HIGH race where two concurrent recordWatch calls would - // each read the old list and one would clobber the other. + // Atomic read-modify-write — two concurrent recordWatch calls + // both reading the same `current` and one clobbering the other + // is exactly the bug updateAndGet avoids. val next = _watches.updateAndGet { current -> val without = current.filterNot { it.videoId == item.videoId } (listOf(now) + without).take(MAX_WATCHES) @@ -67,13 +67,13 @@ class HistoryStore(context: Context) { } fun clearWatches() { - _watches.value = emptyList() - sp.edit().remove(KEY_WATCHES).apply() + _watches.updateAndGet { emptyList() } + sp.edit().putString(KEY_WATCHES, json.encodeToString(emptyList())).apply() } fun clearSearches() { - _searches.value = emptyList() - sp.edit().remove(KEY_SEARCHES).apply() + _searches.updateAndGet { emptyList() } + sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList())).apply() } private fun loadWatches(): List = runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 3a9fc8162..b90d9b406 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * SharedPreferences-lite subscription list. Day-4 graduates to Room when - * we want background feed fetching for new uploads. + * Subscription list backed by a single JSON blob in SharedPreferences. + * Graduates to Room when background feed fetching arrives. */ package com.sulkta.straw.data @@ -38,7 +38,9 @@ class SubscriptionsStore(context: Context) { _subs.value.any { it.url == channelUrl } fun toggle(ref: ChannelRef) { - // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. + // updateAndGet makes the read-modify-write atomic vs. concurrent + // toggles (e.g. one channel subscribed from the feed while another + // is unsubscribed from VideoDetail). val next = _subs.updateAndGet { cur -> if (cur.any { it.url == ref.url }) { cur.filterNot { it.url == ref.url } @@ -50,8 +52,11 @@ class SubscriptionsStore(context: Context) { } fun clear() { - _subs.value = emptyList() - sp.edit().remove(KEY).apply() + // Same atomic-update path as toggle — protects against a concurrent + // toggle racing the clear and persisting [new-item] after the + // remove() call has fired. + _subs.updateAndGet { emptyList() } + persist(emptyList()) } private fun persist(list: List) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index cc33676fe..499f232b1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -5,6 +5,9 @@ package com.sulkta.straw.feature.detail +import android.content.Intent +import android.widget.Toast +import androidx.annotation.OptIn import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,8 +26,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button @@ -35,44 +42,40 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import android.content.Intent -import android.widget.Toast -import androidx.annotation.OptIn -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.sulkta.straw.data.PlaylistItem -import com.sulkta.straw.data.Playlists -import kotlinx.coroutines.delay -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import com.sulkta.straw.feature.download.DownloadKind -import com.sulkta.straw.feature.download.Downloader -import com.sulkta.straw.feature.player.PlayerViewModel -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage +import com.sulkta.straw.OverlayChromeColor +import com.sulkta.straw.OverlayDimColor +import com.sulkta.straw.data.PlaylistItem +import com.sulkta.straw.data.Playlists +import com.sulkta.straw.feature.download.DownloadKind +import com.sulkta.straw.feature.download.Downloader import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.NowPlaying import com.sulkta.straw.feature.player.setPlayingFrom +import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -82,7 +85,7 @@ import com.sulkta.straw.util.stripHtml fun VideoDetailScreen( streamUrl: String, initialTitle: String, - onPlay: (startPositionMs: Long) -> Unit, + onPlay: () -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit, vm: VideoDetailViewModel = viewModel(), @@ -91,13 +94,8 @@ fun VideoDetailScreen( val context = LocalContext.current var showDownloadDialog by remember { mutableStateOf(false) } var showSaveToPlaylistDialog by remember { mutableStateOf(false) } - // Inline-play state. Resets when the user navigates to a different - // video (keyed on streamUrl). + // Inline-play state resets when navigating to a different video. var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } - // V-2: inline player's current position, polled into here so the - // outer can pass it through when the user taps Play / ⛶. Resets to 0 - // when the inline player isn't active. - var inlinePositionMs by remember(streamUrl) { mutableLongStateOf(0L) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } Column( @@ -120,17 +118,13 @@ fun VideoDetailScreen( else -> { val d = state.detail ?: return@Column - // Tap the thumbnail to play inline. Fullscreen button (top-right - // overlay on the inline player) jumps to the fullscreen Player - // screen which has the full toolset. if (inlinePlaying) { InlinePlayer( streamUrl = streamUrl, title = d.title, uploader = d.uploader, thumbnail = d.thumbnail, - onFullscreen = { onPlay(inlinePositionMs) }, - onPositionChanged = { inlinePositionMs = it }, + onFullscreen = onPlay, modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) @@ -154,8 +148,8 @@ fun VideoDetailScreen( Box( modifier = Modifier .size(64.dp) - .clip(androidx.compose.foundation.shape.CircleShape) - .background(Color(0xCC000000)), + .clip(CircleShape) + .background(OverlayDimColor), contentAlignment = Alignment.Center, ) { Icon( @@ -169,25 +163,24 @@ fun VideoDetailScreen( } Spacer(modifier = Modifier.height(12.dp)) - // ── Title + uploader ───────────────────────────────────── Text( text = d.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) Spacer(modifier = Modifier.height(4.dp)) - val uploaderClickable = d.uploaderUrl != null + val uploaderUrl = d.uploaderUrl Text( text = d.uploader, style = MaterialTheme.typography.bodyMedium, - color = if (uploaderClickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = if (uploaderClickable) Modifier.clickable { - onOpenChannel(d.uploaderUrl!!, d.uploader) + color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = if (uploaderUrl != null) Modifier.clickable { + onOpenChannel(uploaderUrl, d.uploader) } else Modifier, ) Spacer(modifier = Modifier.height(12.dp)) - // ── Engagement row: views + RYD likes/dislikes ─────────── Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -225,7 +218,7 @@ fun VideoDetailScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = { onPlay(inlinePositionMs) }) { Text("Play") } + Button(onClick = onPlay) { Text("Play") } OutlinedButton(onClick = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -243,17 +236,15 @@ fun VideoDetailScreen( } Spacer(modifier = Modifier.height(20.dp)) - // ── Description ────────────────────────────────────────── Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.height(8.dp)) - // AUD-MED: cap input length before regex passes — defends - // against ANR on multi-MB descriptions. + // Cap input length before regex passes — defends against + // ANR on multi-MB descriptions. Text( text = stripHtml(d.description.take(20_000)).take(2000), style = MaterialTheme.typography.bodySmall, ) - // ── Recommended ────────────────────────────────────────── if (d.related.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) Text( @@ -264,11 +255,10 @@ fun VideoDetailScreen( Spacer(modifier = Modifier.height(8.dp)) d.related.take(20).forEach { rel -> RelatedRow(rel) { onOpenVideo(rel.url, rel.title) } - androidx.compose.material3.HorizontalDivider() + HorizontalDivider() } } - // ── More from ───────────────────────────────── if (d.moreFromChannel.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) Text( @@ -280,7 +270,7 @@ fun VideoDetailScreen( Spacer(modifier = Modifier.height(8.dp)) d.moreFromChannel.take(20).forEach { item -> RelatedRow(item) { onOpenVideo(item.url, item.title) } - androidx.compose.material3.HorizontalDivider() + HorizontalDivider() } } @@ -315,7 +305,6 @@ fun VideoDetailScreen( confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { - // info is now uniffi.strawcore.StreamInfo (Path C-4). val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url if (audio != null) { val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio) @@ -341,13 +330,12 @@ fun VideoDetailScreen( } }, dismissButton = { - androidx.compose.material3.TextButton(onClick = { showDownloadDialog = false }) { + TextButton(onClick = { showDownloadDialog = false }) { Text("Cancel") } }, ) } - } } } @@ -355,7 +343,7 @@ fun VideoDetailScreen( @Composable private fun RelatedRow( - item: com.sulkta.straw.feature.search.StreamItem, + item: StreamItem, onClick: () -> Unit, ) { Row( @@ -380,7 +368,7 @@ private fun RelatedRow( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(2.dp)) Text( @@ -394,7 +382,7 @@ private fun RelatedRow( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis, ) } } @@ -479,18 +467,18 @@ private fun SaveToPlaylistDialog( } }, confirmButton = { - androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } + TextButton(onClick = onDismiss) { Text("Close") } }, ) } /** - * Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen. - * Uses its own ExoPlayer + PlayerView (with the built-in controller for - * play/pause/seek). A small fullscreen pill in the top-right hops the user - * to the fullscreen PlayerScreen for the full toolset (speed picker, audio- - * only, share, PiP, background). Player is released when the composable - * leaves composition (navigate back or away from VideoDetail). + * Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders + * a PlayerView bound to the shared LocalStrawController — the same + * player as the fullscreen PlayerScreen and the minibar overlay. The ⛶ + * pill hops to fullscreen; playback continues unchanged. There is + * nothing to release here: the controller is process-wide, and the + * PlayerView's surface is detached on dispose via onRelease. */ @OptIn(UnstableApi::class) @Composable @@ -500,42 +488,27 @@ private fun InlinePlayer( uploader: String, thumbnail: String?, onFullscreen: () -> Unit, - onPositionChanged: (Long) -> Unit, modifier: Modifier = Modifier, ) { val controller = LocalStrawController.current - val playerVm: PlayerViewModel = viewModel() - val state by playerVm.ui.collectAsStateWithLifecycle() - LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) } + val vm: VideoDetailViewModel = viewModel() + val state by vm.ui.collectAsStateWithLifecycle() - // As soon as we have a resolved stream AND the active video isn't - // already this URL, push it into the shared controller. The controller - // is the same one driving the fullscreen Player + the minibar overlay, - // so playback survives any nav transition unchanged. + // Push the resolved stream into the shared controller if it isn't + // already playing this URL. We don't kick off a new fetch — the + // outer VideoDetailScreen already called vm.load(streamUrl). val resolved = state.resolved LaunchedEffect(controller, resolved, streamUrl) { val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect - val activeUrl = NowPlaying.current.value?.streamUrl - if (activeUrl != streamUrl) { - c.setPlayingFrom( - streamUrl = streamUrl, - title = title, - uploader = uploader, - thumbnail = thumbnail, - resolved = r, - ) - } - } - - // Report position to the parent on every tick so a Play / ⛶ tap picks - // up at the right spot if the active video is somehow desynced. - LaunchedEffect(controller) { - val c = controller ?: return@LaunchedEffect - while (true) { - onPositionChanged(c.currentPosition.coerceAtLeast(0L)) - delay(500) - } + if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect + c.setPlayingFrom( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + resolved = r, + ) } var playbackError by remember { mutableStateOf(null) } @@ -577,17 +550,16 @@ private fun InlinePlayer( } }, update = { it.player = controller }, + onRelease = { it.player = null }, modifier = Modifier.fillMaxSize(), ) - // Top-right fullscreen pill — hops to the fullscreen - // PlayerScreen which has speed/audio-only/share/PiP/background. Box( modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) .size(36.dp) .clip(RoundedCornerShape(6.dp)) - .background(Color(0xCC222222)) + .background(OverlayChromeColor) .clickable(onClick = onFullscreen), contentAlignment = Alignment.Center, ) { @@ -597,4 +569,3 @@ private fun InlinePlayer( } } } - diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 63dea79c3..476615c25 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -2,11 +2,16 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Phase U-3 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to - * strawcore (Rust + rustypipe via UniFFI). Channel fetch for - * `moreFromChannel` stays on NPE until C-5. + * One VM per video URL — drives VideoDetail, the fullscreen Player, and + * the inline player on detail (all live in the same activity-scoped VM + * store, so `viewModel()` from each composable returns this instance). + * + * `load(url)` fetches strawcore.streamInfo once, derives both `detail` + * (title, uploader, view count, RYD, related, more-from-channel) and + * `resolved` (the picked stream URLs the player needs), and records the + * video to watch history. Subsequent `load(url)` calls for the same URL + * are a no-op so the spinner only fires on a real navigation change. */ - package com.sulkta.straw.feature.detail import androidx.lifecycle.ViewModel @@ -16,7 +21,9 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydVotes +import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SponsorBlockClient +import com.sulkta.straw.feature.search.StreamItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,17 +41,41 @@ data class VideoDetail( val thumbnail: String?, val ryd: RydVotes? = null, val sbSegmentCount: Int = 0, - val related: List = emptyList(), - /** Other videos from the same channel — separate from related (which is YT's - * algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */ - val moreFromChannel: List = emptyList(), + val related: List = emptyList(), + /** + * Other videos from the same channel — separate from `related` + * (which is YT's algo). Anchored to the uploader the user chose; + * matches the sub-feed ethos. + */ + val moreFromChannel: List = emptyList(), ) +/** + * Stream URLs picked from `streamInfo` for the player. The picker prefers + * DASH (whole-quality + adaptive) → HLS → combined progressive → merged + * video+audio progressive → video-only. Carries SB segments for the + * activity-level skip loop. + */ +data class ResolvedPlayback( + val title: String, + val videoUrl: String?, + val audioUrl: String?, + val combinedUrl: String?, + val dashMpdUrl: String?, + val hlsUrl: String?, + val segments: List = emptyList(), +) { + val isPlayable: Boolean + get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() || + !dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank() +} + data class VideoDetailUiState( val loading: Boolean = true, val detail: VideoDetail? = null, + val resolved: ResolvedPlayback? = null, val error: String? = null, - // Stored on success for handoff to the player + Download dialog. Not in UI. + /** Raw extractor result — kept around for the Download dialog. */ val streamInfo: uniffi.strawcore.StreamInfo? = null, ) @@ -55,8 +86,9 @@ class VideoDetailViewModel : ViewModel() { private var loadedUrl: String? = null fun load(streamUrl: String) { - // viewModel() is Activity-scoped, so the same VM is reused across - // navigations. Compare the requested URL with what we last loaded. + // viewModel() is activity-scoped, so the same VM is reused across + // navigations. Skip the refetch if the requested URL already has + // a resolved state. if (loadedUrl == streamUrl && _ui.value.detail != null) return loadedUrl = streamUrl _ui.value = VideoDetailUiState(loading = true) @@ -86,14 +118,13 @@ class VideoDetailViewModel : ViewModel() { runCatching { RydClient.fetch(videoId) }.getOrNull() } val sbCats = Settings.get().sbCategories.value.map { it.key } - val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { - runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) + val segments = if (sbCats.isEmpty()) emptyList() else withContext(Dispatchers.IO) { + runCatching { SponsorBlockClient.fetch(videoId, sbCats) } + .getOrDefault(emptyList()) } - // strawcore returns `related` as List. Map to the - // Kotlin StreamItem shape used elsewhere. val related = info.related.map { r -> - com.sulkta.straw.feature.search.StreamItem( + StreamItem( url = r.url, title = r.title.ifBlank { "(no title)" }, uploader = r.uploader, @@ -107,7 +138,7 @@ class VideoDetailViewModel : ViewModel() { // More from this channel via strawcore.channelInfo — one // Rust round-trip returns the channel's Videos tab pre-mapped. val uploaderUrl = info.uploaderUrl - val moreFromChannel: List = + val moreFromChannel: List = if (uploaderUrl.isNullOrBlank()) emptyList() else runCatching { val ch = uniffi.strawcore.channelInfo(uploaderUrl) @@ -115,7 +146,7 @@ class VideoDetailViewModel : ViewModel() { .filter { it.url != streamUrl } .take(20) .map { v -> - com.sulkta.straw.feature.search.StreamItem( + StreamItem( url = v.url, title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { uploader }, @@ -127,6 +158,8 @@ class VideoDetailViewModel : ViewModel() { } }.getOrDefault(emptyList()) + val resolved = resolvePlayback(info, segments) + _ui.value = VideoDetailUiState( loading = false, detail = VideoDetail( @@ -138,10 +171,11 @@ class VideoDetailViewModel : ViewModel() { description = info.description, thumbnail = thumb, ryd = ryd, - sbSegmentCount = sbCount, + sbSegmentCount = segments.size, related = related, moreFromChannel = moreFromChannel, ), + resolved = resolved, streamInfo = info, ) } catch (t: Throwable) { @@ -152,4 +186,28 @@ class VideoDetailViewModel : ViewModel() { } } } + + private fun resolvePlayback( + info: uniffi.strawcore.StreamInfo, + segments: List, + ): ResolvedPlayback { + val maxRes = Settings.get().maxResolution.value.ceiling + // Filter by max-resolution ceiling but fall back to the lowest + // available if the ceiling excludes everything (e.g. a 360p-only + // upload with the user on a 480p cap). + fun pickVideo(streams: List): String? { + if (streams.isEmpty()) return null + val pool = streams.filter { it.height <= maxRes }.ifEmpty { streams } + return pool.maxByOrNull { it.bitrate }?.url + } + return ResolvedPlayback( + title = info.title, + videoUrl = pickVideo(info.videoOnly), + audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url, + combinedUrl = pickVideo(info.combined), + dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, + hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, + segments = segments, + ) + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt index 4bb361506..bdb7dcce3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt @@ -2,18 +2,18 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Phase R: minimal download via Android's DownloadManager. Saves to the + * Minimal download via Android's DownloadManager. Saves to the * app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE * on older Android. The user can pull files out via a file manager * (under Android/data/com.sulkta.straw.debug/files/...). * - * Audit fixes (2026-05-24 pass #2): - * HIGH-4: scheme + host validation on the URL before handing it to - * DownloadManager — extractor output is not trusted root-of-truth. - * HIGH-5: harder filename sanitization — control chars, bidi overrides, - * leading dots, trailing whitespace. - * MED-6: catch IllegalArgumentException from enqueue so a malformed URI - * doesn't crash the click handler. + * Hardening: + * - scheme + host validation on the URL before enqueueing (extractor + * output is not trusted root-of-truth) + * - filename sanitization for control chars, bidi overrides, leading + * dots, and trailing whitespace + * - catches IllegalArgumentException from enqueue so a malformed URI + * doesn't crash the click handler */ package com.sulkta.straw.feature.download @@ -88,8 +88,9 @@ object Downloader { val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false if (!uri.scheme.equals("https", ignoreCase = true)) return false val host = uri.host?.lowercase() ?: return false - return host.endsWith(".googlevideo.com") || - host.endsWith(".youtube.com") || - host == "youtube.com" + // strawcore returns video/audio stream URLs from googlevideo CDN + // exclusively — youtube.com URLs aren't direct streams and have + // no business going to DownloadManager. + return host.endsWith(".googlevideo.com") } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index d7f27b11c..f69be037f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -2,26 +2,26 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Phase Q: aggregate latest videos across all subscribed channels into a - * single feed. Fans out per-channel channelInfo() fetches in parallel, - * merges by view count desc, caps at 200 items. + * Aggregate latest videos across all subscribed channels into a single + * feed. Fans out per-channel channelInfo() fetches in parallel, caches + * each channel's videos independently, merges by view-count desc, caps + * at 200 items. * - * Path C-5: each per-channel fetch is now ONE strawcore.channelInfo() - * call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo + - * ChannelTabInfo.getInfo). Halves the network work for the feed. + * Each per-channel cache entry has its own TTL so adding one new + * subscription doesn't invalidate the other 49 — only the new one + * actually goes to the network on the next refresh. * - * Audit fixes (2026-05-24 pass #2): - * HIGH-6: cancel any prior in-flight refresh when a new one starts, cap - * concurrency with a Semaphore, time-bound each per-channel fetch so - * one hung channel can't stall the whole feed. - * MED-7: use `update { }` for atomic UI-state writes (matches the - * convention applied to the data stores in audit pass #1). + * Concurrency hardening: cancel any in-flight refresh when a new one + * starts, cap parallelism with a Semaphore so 100+ subs don't slam YT, + * time-bound each per-channel fetch so one hung channel can't stall the + * whole batch. */ package com.sulkta.straw.feature.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap data class SubscriptionFeedUiState( val loading: Boolean = false, @@ -49,10 +50,14 @@ class SubscriptionFeedViewModel : ViewModel() { private val _ui = MutableStateFlow(SubscriptionFeedUiState()) val ui: StateFlow = _ui.asStateFlow() - /** Cache feed for 10 min to avoid hammering YT on tab re-entry. */ - private val cacheTtlMs = 10L * 60 * 1000 + /** Per-channel cache: each entry refreshes independently. */ + private data class ChannelCacheEntry(val fetchedAt: Long, val items: List) + private val channelCache = ConcurrentHashMap() - /** Per-channel fetch timeout — slowest channel can't stall the whole batch. */ + /** Per-channel TTL — Refresh just re-fetches stale entries. */ + private val perChannelTtlMs = 10L * 60 * 1000 + + /** Per-channel fetch timeout — slowest channel can't stall the batch. */ private val perChannelTimeoutMs = 15_000L /** Cap parallel network fetches even with 100+ subs. */ @@ -63,64 +68,39 @@ class SubscriptionFeedViewModel : ViewModel() { fun refreshIfStale() { val now = System.currentTimeMillis() - if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return - refresh() + val anyStale = Subscriptions.get().subs.value.any { ch -> + val entry = channelCache[ch.url] + entry == null || now - entry.fetchedAt >= perChannelTtlMs + } + if (anyStale || _ui.value.items.isEmpty()) refresh() } fun refresh() { val channels = Subscriptions.get().subs.value if (channels.isEmpty()) { _ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) } + channelCache.clear() return } inFlight?.cancel() _ui.update { it.copy(loading = true, error = null) } inFlight = viewModelScope.launch { try { - val perChannelMax = 5 val gate = Semaphore(parallelism) - val items = coroutineScope { - val deferreds = channels.map { ch -> - async { - gate.withPermit { - withTimeoutOrNull(perChannelTimeoutMs) { - runCatching { - val info = uniffi.strawcore.channelInfo(ch.url) - info.videos - .take(perChannelMax) - .map { v -> - StreamItem( - url = v.url, - title = v.title.ifBlank { "(no title)" }, - uploader = v.uploader.ifBlank { ch.name }, - uploaderUrl = v.uploaderUrl ?: ch.url, - thumbnail = v.thumbnail, - durationSeconds = v.durationSeconds, - viewCount = v.viewCount, - ) - } - }.onFailure { - strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } - }.getOrDefault(emptyList()) - } ?: run { - strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } - emptyList() - } - } + val now = System.currentTimeMillis() + coroutineScope { + channels + .filter { ch -> + val entry = channelCache[ch.url] + entry == null || now - entry.fetchedAt >= perChannelTtlMs } - } - deferreds.awaitAll() + .map { ch -> async { gate.withPermit { fetchChannelInto(ch) } } } + .awaitAll() } - .flatten() - // No reliable upload-timestamp on the search-item shape — sort - // by view count desc as a soft proxy for recency-popularity - // within the recent window. - .sortedByDescending { it.viewCount } - .take(200) _ui.update { SubscriptionFeedUiState( loading = false, - items = items, + items = mergeFromCache(channels), lastFetchedAt = System.currentTimeMillis(), ) } @@ -134,4 +114,45 @@ class SubscriptionFeedViewModel : ViewModel() { } } } + + private suspend fun fetchChannelInto(ch: ChannelRef) { + val perChannelMax = 5 + val fetched = withTimeoutOrNull(perChannelTimeoutMs) { + runCatching { + val info = uniffi.strawcore.channelInfo(ch.url) + info.videos.take(perChannelMax).map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { ch.name }, + uploaderUrl = v.uploaderUrl ?: ch.url, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) + } + }.onFailure { + strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } + }.getOrDefault(emptyList()) + } ?: run { + strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } + emptyList() + } + // Only update the cache on a successful fetch. A timeout/error + // leaves any prior cache entry intact, so a glitchy channel + // doesn't blank your feed for that channel. + if (fetched.isNotEmpty()) { + channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), fetched) + } + } + + private fun mergeFromCache(channels: List): List { + val subUrls = channels.map { it.url }.toSet() + // Drop cache entries for unsubscribed channels so removed subs + // fall out of the feed immediately. + channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) } + return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() } + .sortedByDescending { it.viewCount } + .take(200) + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt deleted file mode 100644 index e7f57495a..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Sulkta-Coop - * SPDX-License-Identifier: GPL-3.0-or-later - * - * Bridge between StrawActivity.onUserLeaveHint() (HOME / recents button) - * and the active PlayerScreen. When the user leaves the player by pressing - * HOME, we want a seamless hand-off to the background-audio foreground - * service — not Picture-in-Picture. PlayerScreen registers a handler on - * compose, clears it on dispose; the activity calls it from the OS hook. - */ - -package com.sulkta.straw.feature.player - -object PlayerLeaveHandler { - @Volatile - var handler: (() -> Unit)? = null -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 173718f7a..0f4339d6f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -2,19 +2,12 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Fullscreen player surface. After the V-2 unification, the player - * itself lives in PlaybackService (one ExoPlayer for the whole app). - * This composable is a thin shell that: - * 1. Asks the PlayerViewModel to resolve the stream URL - * 2. Pushes the resolved MediaItem into the shared MediaController - * 3. Renders PlayerView bound to that controller - * 4. Runs the SponsorBlock skip loop against the controller - * 5. Lets the user drag-down to dismiss into the minibar - * - * Audio-only toggle, speed picker, share, manual PiP, and the - * background-audio button stay as overlays. Audio-only flips the - * controller's track-selection params; nothing more to do because - * playback is one player. + * Fullscreen player surface. The player itself lives in PlaybackService + * (one ExoPlayer for the whole app); this composable is a thin shell that + * renders a PlayerView bound to the shared MediaController, lets the user + * drag down to dismiss into the minibar, and overlays speed / audio-only + * / share / PiP / minimize controls. SponsorBlock auto-skip lives at the + * activity root in [SponsorBlockSkipLoop]. */ package com.sulkta.straw.feature.player @@ -35,12 +28,10 @@ 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.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -65,7 +56,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.foundation.layout.offset import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.C @@ -74,6 +64,8 @@ import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.UnstableApi import androidx.media3.ui.PlayerView +import com.sulkta.straw.OverlayChromeColor +import com.sulkta.straw.feature.detail.VideoDetailViewModel import com.sulkta.straw.net.SbSegment import com.sulkta.straw.util.strawLogI import kotlin.math.roundToInt @@ -85,67 +77,46 @@ import kotlinx.coroutines.launch fun PlayerScreen( streamUrl: String, title: String, - startPositionMs: Long = 0L, onMinimize: () -> Unit = {}, - vm: PlayerViewModel = viewModel(), + vm: VideoDetailViewModel = viewModel(), ) { val context = LocalContext.current val controller = LocalStrawController.current val state by vm.ui.collectAsStateWithLifecycle() - LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } + LaunchedEffect(streamUrl) { vm.load(streamUrl) } var playbackSpeed by remember { mutableStateOf(1.0f) } var audioOnly by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } // Drag-to-minimize: vertical offset accumulated during the gesture. - // On release, if past threshold we dismiss into the minibar. + // On release past the threshold we dismiss into the minibar. val density = LocalDensity.current val dismissThresholdPx = with(density) { 200.dp.toPx() } val dragY = remember { Animatable(0f) } val scope = rememberCoroutineScope() - // Push the resolved video into the shared controller as soon as we - // have stream URLs. If something else is already playing the same - // streamUrl, just seek instead of re-loading. - // For metadata that vm.resolve doesn't return (uploader / thumbnail) we - // try to lift them from the matching VideoDetail item if it's open in - // the same nav stack; otherwise fall back to whatever NowPlaying - // already has. Either way the minibar gets enough to render. - val detailVm: com.sulkta.straw.feature.detail.VideoDetailViewModel = viewModel() - LaunchedEffect(streamUrl) { detailVm.load(streamUrl) } - val detailState by detailVm.ui.collectAsStateWithLifecycle() + // When the resolved playback for this URL is ready, push it into the + // shared controller — unless it's already playing this exact URL, in + // which case do nothing: the player is already where we want it. The + // previous "seek-to-self" path here was always a few ms backwards and + // produced a jerk on every entry; the controller's currentPosition is + // its own source of truth. val resolved = state.resolved - LaunchedEffect(controller, resolved, detailState.detail) { + val detail = state.detail + LaunchedEffect(controller, resolved, detail) { val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect - val d = detailState.detail - val uploader = d?.uploader ?: NowPlaying.current.value?.uploader.orEmpty() - val thumbnail = d?.thumbnail ?: NowPlaying.current.value?.thumbnail - val sameVideo = NowPlaying.current.value?.streamUrl == streamUrl - val currentTitle = c.mediaMetadata.title?.toString() - if (sameVideo && currentTitle == title) { - if (startPositionMs > 0) c.seekTo(startPositionMs) - if (!c.isPlaying) c.play() - NowPlaying.set( - NowPlayingItem( - streamUrl = streamUrl, - title = title, - uploader = uploader, - thumbnail = thumbnail, - segments = r.segments, - ), - ) - } else { - c.setPlayingFrom( - streamUrl = streamUrl, - title = title, - uploader = uploader, - thumbnail = thumbnail, - resolved = r, - startPositionMs = startPositionMs, - ) - } + val uploader = detail?.uploader.orEmpty() + val thumbnail = detail?.thumbnail + if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect + c.setPlayingFrom( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + resolved = r, + ) } // Surface ExoPlayer failures from the service into the UI. @@ -161,9 +132,6 @@ fun PlayerScreen( onDispose { c?.removeListener(listener) } } - // Manual-PiP wiring (the ⊟ overlay button). The activity is the PiP - // host; we just feed it the right params. Auto-enter-on-home stays - // disabled — HOME triggers seamless minibar/background per #255. val activity = context as? Activity Box( @@ -174,7 +142,6 @@ fun PlayerScreen( detectVerticalDragGestures( onDragEnd = { if (dragY.value > dismissThresholdPx) { - // Snap to dismiss + pop into minibar. onMinimize() } else { scope.launch { dragY.animateTo(0f, tween(180)) } @@ -185,7 +152,6 @@ fun PlayerScreen( }, onVerticalDrag = { _, dy -> scope.launch { - // Clamp to non-negative — upward drag has no effect. dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) } }, @@ -222,26 +188,23 @@ fun PlayerScreen( } }, update = { it.player = controller }, + onRelease = { it.player = null }, modifier = Modifier.fillMaxSize(), ) - // SponsorBlock segment count badge — small overlay top-left. - resolved.let { r -> - Box( - modifier = Modifier - .align(Alignment.TopStart) - .padding(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xCC222222)) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = "SB: ${r.segments.size} segment${if (r.segments.size == 1) "" else "s"}", - color = Color.White, - style = MaterialTheme.typography.labelSmall, - ) - } + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(OverlayChromeColor) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = "SB: ${resolved.segments.size} segment${if (resolved.segments.size == 1) "" else "s"}", + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) } - // Top-right overlay — speed / audio-only / share / PiP / minimize. Row( modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -288,7 +251,6 @@ fun PlayerScreen( Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() } } - // Explicit minimize button — same effect as drag-down. OverlayButton(label = "⌄") { onMinimize() } } @@ -314,7 +276,7 @@ private fun OverlayButton(label: String, onClick: () -> Unit) { modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) - .background(Color(0xCC222222)) + .background(OverlayChromeColor) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -360,9 +322,12 @@ private fun SpeedPickerDialog( /** * SponsorBlock skip loop driven by the controller's currentPosition. - * Runs at the activity composition root (not per-screen) so it skips - * segments whether the user is fullscreen, in the minibar, or away from - * the player surface. + * Lives at the activity composition root so it skips segments whether + * the user is fullscreen, in the minibar, or away from the player + * surface. + * + * The `skipped` set is only mutated from this single coroutine — safe + * without synchronization while that invariant holds. */ @Composable @OptIn(UnstableApi::class) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt deleted file mode 100644 index 5dd9eeaa3..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Sulkta-Coop - * SPDX-License-Identifier: GPL-3.0-or-later - * - * Phase U-3 / Path C-4: extractor moved from NewPipeExtractor (Java) to - * strawcore (Rust + rustypipe via UniFFI). PlayerScreen still calls - * vm.resolve(url) the same way — the engine underneath flipped. - */ - -package com.sulkta.straw.feature.player - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.sulkta.straw.data.Settings -import com.sulkta.straw.net.SbSegment -import com.sulkta.straw.net.SponsorBlockClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -data class ResolvedPlayback( - val title: String, - val videoUrl: String?, - val audioUrl: String?, - val combinedUrl: String?, - val dashMpdUrl: String?, - val hlsUrl: String?, - val segments: List = emptyList(), -) { - /** Have anything playable? */ - val isPlayable: Boolean - get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() || - !dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank() -} - -data class PlayerUiState( - val loading: Boolean = true, - val resolved: ResolvedPlayback? = null, - val error: String? = null, -) - -class PlayerViewModel : ViewModel() { - private val _ui = MutableStateFlow(PlayerUiState()) - val ui: StateFlow = _ui.asStateFlow() - - fun resolve(streamUrl: String) { - _ui.value = PlayerUiState(loading = true) - viewModelScope.launch { - try { - // strawcore.streamInfo is a suspend fun running on the tokio - // runtime baked into libstrawcore.so — no Dispatchers.IO needed. - val info = uniffi.strawcore.streamInfo(streamUrl) - val videoId = info.id - - val sbCategories = Settings.get().sbCategories.value.map { it.key } - val segments = if (sbCategories.isEmpty()) { - emptyList() - } else { - withContext(Dispatchers.IO) { - runCatching { SponsorBlockClient.fetch(videoId, sbCategories) } - .getOrDefault(emptyList()) - } - } - - val maxRes = Settings.get().maxResolution.value.ceiling - - // Audit HIGH-8 carry-over: filter by max resolution but fall - // back to lowest available if the ceiling excludes everything. - fun pickVideo(streams: List): String? { - if (streams.isEmpty()) return null - val filtered = streams.filter { it.height <= maxRes } - val pool = filtered.ifEmpty { streams } - return pool.maxByOrNull { it.bitrate }?.url - } - - val combined = pickVideo(info.combined) - val videoOnly = pickVideo(info.videoOnly) - val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url - - _ui.value = PlayerUiState( - loading = false, - resolved = ResolvedPlayback( - title = info.title, - videoUrl = videoOnly, - audioUrl = audioOnly, - combinedUrl = combined, - dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, - hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, - segments = segments, - ), - ) - } catch (t: Throwable) { - _ui.value = PlayerUiState( - loading = false, - error = t.message ?: t.javaClass.simpleName, - ) - } - } - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 6ad3d376f..498dc96a9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -41,6 +41,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors +import com.sulkta.straw.feature.detail.ResolvedPlayback val LocalStrawController = compositionLocalOf { null } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 338388af9..e48aecd83 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -41,15 +41,11 @@ class SearchViewModel : ViewModel() { fun submit() { val q = _ui.value.query.trim() if (q.isEmpty()) return - runCatching { History.get().recordSearch(q) } _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList()) viewModelScope.launch { try { - // Phase U-2 / Path C-3: rustypipe via UniFFI. The bindgen-generated - // `search()` is already a suspend fun running on the tokio runtime - // baked into libstrawcore.so — no Dispatchers.IO wrapper needed, - // the JNI call returns to us on the caller dispatcher when the - // future completes. + // strawcore.search() is suspend on the tokio runtime baked + // into libstrawcore.so — no Dispatchers.IO wrap needed. val rustItems = uniffi.strawcore.search(q) val items = rustItems.map { r -> StreamItem( @@ -63,6 +59,9 @@ class SearchViewModel : ViewModel() { ) } _ui.value = _ui.value.copy(loading = false, results = items) + // Record AFTER the search succeeds so mistyped queries + // that error out don't pollute the recent-searches list. + runCatching { History.get().recordSearch(q) } } catch (t: Throwable) { _ui.value = _ui.value.copy( loading = false, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt index f228b23d2..b4933d38a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/HtmlText.kt @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Strip HTML tags from NewPipeExtractor's description.content for plain-text - * rendering. Day-3 polish replaces this with a real Markwon/Compose annotated - * renderer; for now we just want readable text. + * Strip HTML tags from video descriptions for plain-text rendering. + * Replace with a real annotated renderer (Markwon, Compose annotated + * strings) when the description UI needs richer formatting. */ package com.sulkta.straw.util From 885398e3bddc89f6e478d5df98f2cd84fd1f5691 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 17:46:23 +0000 Subject: [PATCH 10/87] =?UTF-8?q?vc=3D26:=20look=20+=20feel=20pass=20?= =?UTF-8?q?=E2=80=94=20sulkta.com=20palette=20+=20Material=20Icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color palette pulled directly from sulkta.com's stylesheet — the same greens used on the website now drive the app theme: #166534 deep green (light-theme primary, top app bar background) #4ade80 bright lime (dark-theme primary, accents in dark mode) #86efac light green (primaryContainer in light theme) #e8f5e8 pale green (secondary container tint) #d97706 amber accent (tertiary) #374137 olive gray (secondary on light, container on dark) Replaces the made-up forest palette from vc=23 with the real Sulkta brand. Same M3 tonal-role mapping so derived surfaces stay consistent. TopAppBar redone NewPipe-style: solid deep-green bar with white "straw" title, white hamburger + search icons. Clear bold header instead of the previous white-with-a-pill-underneath layout. Material Icons swapped in everywhere we had emoji: drawer Person / History / PlaylistPlay / Download / Settings minibar PlayArrow / Pause / Close fullscreen Speed / Headphones / Videocam / Share / PictureInPictureAlt / KeyboardArrowDown Pulled in material-icons-extended (4 MB APK growth, all icons). Consistent renders across vendors; no more emoji font fallback drift. FeedRow gets a NewPipe-style duration pill burned into the bottom-right of every thumbnail (mm:ss / h:mm:ss). Live streams / mixes with no duration leave it off. Audit deferred-MED items addressed: MED-6: dropped the PlayerService STATE_ENDED auto-stop. Service shutdown is now driven only by onTaskRemoved + the minibar's ×. Removes the implicit "we'll never queue" assumption and is correct for a future autoplay/queue feature. LOW-7: DownloadsScreen adaptive poll — 1s while a download is active, 5s when idle. No more wasted DB queries when nothing is running. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/build.gradle.kts | 2 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 121 ++++++++++++------ .../kotlin/com/sulkta/straw/StrawTheme.kt | 106 ++++++++------- .../straw/feature/download/DownloadsScreen.kt | 17 ++- .../straw/feature/player/MinibarOverlay.kt | 20 ++- .../straw/feature/player/PlaybackService.kt | 20 +-- .../straw/feature/player/PlayerScreen.kt | 41 ++++-- 8 files changed, 207 insertions(+), 124 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 12e988a7a..fdfb000d4 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 25 -const val STRAW_VERSION_NAME = "0.1.0-AK" +const val STRAW_VERSION_CODE = 26 +const val STRAW_VERSION_NAME = "0.1.0-AL" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 774719420..bb98352e7 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -81,7 +81,7 @@ dependencies { implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.ui) - implementation("androidx.compose.material:material-icons-core:1.7.5") + implementation("androidx.compose.material:material-icons-extended:1.7.5") // Lifecycle + ViewModel for Compose implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 0512de4aa..85c4b9a6e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -8,8 +8,10 @@ package com.sulkta.straw +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 @@ -25,7 +27,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -36,14 +44,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -68,6 +74,7 @@ import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.util.formatViews import kotlinx.coroutines.launch @@ -109,7 +116,7 @@ fun StrawHome( NavigationDrawerItem( label = { Text("Subscriptions") }, - icon = { Text("👤") }, + icon = { Icon(Icons.Filled.Person, contentDescription = null) }, selected = view == HomeView.Subs, onClick = { view = HomeView.Subs @@ -119,7 +126,7 @@ fun StrawHome( ) NavigationDrawerItem( label = { Text("History") }, - icon = { Text("📺") }, + icon = { Icon(Icons.Filled.History, contentDescription = null) }, selected = view == HomeView.History, onClick = { view = HomeView.History @@ -129,7 +136,7 @@ fun StrawHome( ) NavigationDrawerItem( label = { Text("Playlists") }, - icon = { Text("📃") }, + icon = { Icon(Icons.Filled.PlaylistPlay, contentDescription = null) }, selected = false, onClick = { scope.launch { drawerState.close() } @@ -139,7 +146,7 @@ fun StrawHome( ) NavigationDrawerItem( label = { Text("Downloads") }, - icon = { Text("⬇") }, + icon = { Icon(Icons.Filled.Download, contentDescription = null) }, selected = false, onClick = { scope.launch { drawerState.close() } @@ -150,7 +157,7 @@ fun StrawHome( HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) NavigationDrawerItem( label = { Text("Settings") }, - icon = { Text("⚙") }, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, selected = false, onClick = { scope.launch { drawerState.close() } @@ -163,42 +170,33 @@ fun StrawHome( ) { Scaffold( topBar = { + // Green-tinted bar inspired by NewPipe/Tubular's colored + // header, but using our forest-green primary container so + // it sits cleanly with the rest of the Material 3 surfaces. TopAppBar( title = { - // Search-pill in the title slot — tap takes you to the - // full search screen with the field auto-focused. Same - // idea as YT's mobile top bar. - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(end = 8.dp) - .height(40.dp) - .clip(RoundedCornerShape(20.dp)) - .clickable(onClick = onOpenSearch), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 14.dp), - ) { - Text( - "🔍", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - "Search YouTube", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + Text( + "straw", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) }, navigationIcon = { IconButton(onClick = { scope.launch { drawerState.open() } }) { Icon(Icons.Filled.Menu, contentDescription = "Menu") } }, + actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Filled.Search, contentDescription = "Search") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), ) }, ) { padding -> @@ -352,13 +350,12 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) { .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + ThumbnailWithDuration( + thumbnail = item.thumbnail, + durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { @@ -387,6 +384,48 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) { } } +/** + * 16:9 thumbnail with a NewPipe-style duration pill burned into the + * bottom-right corner. `durationSeconds == 0` skips the badge (live + * streams, mixes that come back without a duration, etc.). + */ +@Composable +private fun ThumbnailWithDuration( + thumbnail: String?, + durationSeconds: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncImage( + model = thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(6.dp)), + ) + if (durationSeconds > 0) { + Text( + text = formatDurationShort(durationSeconds), + style = MaterialTheme.typography.labelSmall, + color = androidx.compose.ui.graphics.Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(RoundedCornerShape(3.dp)) + .background(OverlayDimColor) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } +} + +private fun formatDurationShort(totalSec: Long): String { + val h = totalSec / 3600 + val m = (totalSec % 3600) / 60 + val s = totalSec % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) +} + @Composable private fun SubChip( ch: ChannelRef, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt index c3ee116ec..ebbacd377 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -2,10 +2,19 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Sulkta green palette for Straw. Replaces the M3 default lavender/red - * tints with a clean forest green primary — modern, clean, distinct from - * NewPipe / Tubular's red. Same Tonal Palette structure Material 3 uses - * internally so all the derived surfaces stay in harmony. + * Straw palette pulled directly from sulkta.com's stylesheet: + * #4ade80 primary green (Tailwind green-400, most-used on the site) + * #166534 deep green (green-800, headings + emphasis) + * #22c55e mid green (green-500, links + buttons) + * #86efac light green container (green-300) + * #e8f5e8 pale green tint + * #d97706 amber accent (sulkta.com calls this out for chips) + * #374137 olive-gray secondary + * #0a0a0a near-black text on light + * #111411 near-black with green tint for dark surface + * + * Mapped into Material 3's primary / secondary / tertiary tonal roles + * so all the derived M3 surfaces (containers, outlines, etc.) follow. */ package com.sulkta.straw @@ -15,56 +24,61 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color -private val GreenPrimary = Color(0xFF386A1F) -private val GreenOnPrimary = Color(0xFFFFFFFF) -private val GreenPrimaryContainer = Color(0xFFB6F397) -private val GreenOnPrimaryContainer = Color(0xFF0B2000) -private val GreenSecondary = Color(0xFF55624C) -private val GreenOnSecondary = Color(0xFFFFFFFF) -private val GreenSecondaryContainer = Color(0xFFD8E7CB) -private val GreenOnSecondaryContainer = Color(0xFF131F0D) -private val GreenTertiary = Color(0xFF386666) -private val GreenOnTertiary = Color(0xFFFFFFFF) +// Light theme — primary is sulkta.com's deep green (#166534), strong +// enough for white text and matches the site's heading emphasis. +private val LPrimary = Color(0xFF166534) +private val LOnPrimary = Color(0xFFFFFFFF) +private val LPrimaryContainer = Color(0xFF86EFAC) +private val LOnPrimaryContainer = Color(0xFF0A0A0A) +private val LSecondary = Color(0xFF374137) +private val LOnSecondary = Color(0xFFFFFFFF) +private val LSecondaryContainer = Color(0xFFE8F5E8) +private val LOnSecondaryContainer = Color(0xFF0A0A0A) +private val LTertiary = Color(0xFFD97706) +private val LOnTertiary = Color(0xFFFFFFFF) -private val DarkGreenPrimary = Color(0xFF9BD67D) -private val DarkGreenOnPrimary = Color(0xFF153800) -private val DarkGreenPrimaryContainer = Color(0xFF205107) -private val DarkGreenOnPrimaryContainer = Color(0xFFB6F397) -private val DarkGreenSecondary = Color(0xFFBDCBB0) -private val DarkGreenOnSecondary = Color(0xFF283420) -private val DarkGreenSecondaryContainer = Color(0xFF3E4A35) -private val DarkGreenOnSecondaryContainer = Color(0xFFD8E7CB) -private val DarkGreenTertiary = Color(0xFFA0CFD0) -private val DarkGreenOnTertiary = Color(0xFF003738) +// Dark theme — primary is sulkta.com's bright lime (#4ade80) since dark +// backgrounds need a brighter accent for readability. PrimaryContainer +// is the deep green so emphasis stays consistent across themes. +private val DPrimary = Color(0xFF4ADE80) +private val DOnPrimary = Color(0xFF0A0A0A) +private val DPrimaryContainer = Color(0xFF166534) +private val DOnPrimaryContainer = Color(0xFF86EFAC) +private val DSecondary = Color(0xFF9AB89A) +private val DOnSecondary = Color(0xFF111411) +private val DSecondaryContainer = Color(0xFF374137) +private val DOnSecondaryContainer = Color(0xFFE8F5E8) +private val DTertiary = Color(0xFFD97706) +private val DOnTertiary = Color(0xFF0A0A0A) fun strawLightColors(): ColorScheme = lightColorScheme( - primary = GreenPrimary, - onPrimary = GreenOnPrimary, - primaryContainer = GreenPrimaryContainer, - onPrimaryContainer = GreenOnPrimaryContainer, - secondary = GreenSecondary, - onSecondary = GreenOnSecondary, - secondaryContainer = GreenSecondaryContainer, - onSecondaryContainer = GreenOnSecondaryContainer, - tertiary = GreenTertiary, - onTertiary = GreenOnTertiary, + primary = LPrimary, + onPrimary = LOnPrimary, + primaryContainer = LPrimaryContainer, + onPrimaryContainer = LOnPrimaryContainer, + secondary = LSecondary, + onSecondary = LOnSecondary, + secondaryContainer = LSecondaryContainer, + onSecondaryContainer = LOnSecondaryContainer, + tertiary = LTertiary, + onTertiary = LOnTertiary, ) fun strawDarkColors(): ColorScheme = darkColorScheme( - primary = DarkGreenPrimary, - onPrimary = DarkGreenOnPrimary, - primaryContainer = DarkGreenPrimaryContainer, - onPrimaryContainer = DarkGreenOnPrimaryContainer, - secondary = DarkGreenSecondary, - onSecondary = DarkGreenOnSecondary, - secondaryContainer = DarkGreenSecondaryContainer, - onSecondaryContainer = DarkGreenOnSecondaryContainer, - tertiary = DarkGreenTertiary, - onTertiary = DarkGreenOnTertiary, + primary = DPrimary, + onPrimary = DOnPrimary, + primaryContainer = DPrimaryContainer, + onPrimaryContainer = DOnPrimaryContainer, + secondary = DSecondary, + onSecondary = DOnSecondary, + secondaryContainer = DSecondaryContainer, + onSecondaryContainer = DOnSecondaryContainer, + tertiary = DTertiary, + onTertiary = DOnTertiary, ) // Semi-transparent overlays for chrome (overlay buttons, the SB badge, // the inline-player fullscreen pill) and for the dimmed area behind the // minibar thumbnail. Kept here so a theme tweak touches one place. -val OverlayChromeColor = androidx.compose.ui.graphics.Color(0xCC222222) -val OverlayDimColor = androidx.compose.ui.graphics.Color(0xCC000000) +val OverlayChromeColor = Color(0xCC222222) +val OverlayDimColor = Color(0xCC000000) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt index f3262cd41..5872a5c17 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -86,14 +86,19 @@ fun DownloadsScreen() { val context = LocalContext.current var rows by remember { mutableStateOf>(emptyList()) } - // Poll DownloadManager every second while the screen is visible. - // DownloadManager doesn't broadcast progress, so polling is the - // standard pattern. Cheap query — single cursor across the app's own - // download queue. + // DownloadManager doesn't broadcast progress, so we poll while the + // screen is visible. Fast cadence (1s) when something is actively + // running, slow cadence (5s) when everything is settled — no + // animations to update. LaunchedEffect(Unit) { while (true) { - rows = queryDownloads(context) - delay(1000) + val fresh = queryDownloads(context) + rows = fresh + val active = fresh.any { + it.status == DownloadManager.STATUS_RUNNING || + it.status == DownloadManager.STATUS_PENDING + } + delay(if (active) 1000 else 5000) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt index b1d98bcb6..dea622a58 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -27,7 +27,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -113,10 +118,13 @@ fun MinibarOverlay( ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - MinibarIconButton(label = if (isPlaying) "⏸" else "▶") { + MinibarIconButton( + icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + desc = if (isPlaying) "Pause" else "Play", + ) { if (controller.isPlaying) controller.pause() else controller.play() } - MinibarIconButton(label = "×") { + MinibarIconButton(icon = Icons.Filled.Close, desc = "Stop") { controller.stop() controller.clearMediaItems() NowPlaying.clear() @@ -128,7 +136,11 @@ fun MinibarOverlay( } @Composable -private fun MinibarIconButton(label: String, onClick: () -> Unit) { +private fun MinibarIconButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + desc: String, + onClick: () -> Unit, +) { Box( modifier = Modifier .size(44.dp) @@ -136,6 +148,6 @@ private fun MinibarIconButton(label: String, onClick: () -> Unit) { .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { - Text(label, style = MaterialTheme.typography.titleMedium) + Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp)) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 61e0f1c47..95902fa6d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -79,14 +79,11 @@ class PlaybackService : MediaSessionService() { ) .build() - // Stop ourselves once playback genuinely ends so the foreground slot - // is released. STATE_IDLE + STATE_ENDED both qualify; STATE_BUFFERING - // / STATE_READY mean we're still doing work even if paused. - player.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(state: Int) { - if (state == Player.STATE_ENDED) stopSelfWhenIdle() - } - }) + // Service shutdown is driven by onTaskRemoved (user swiped app away) + // + the user pressing × on the minibar (which clears the queue). + // Don't auto-stop on STATE_ENDED — a future autoplay/queue feature + // expects the service to stay alive between items in the queue. + // Foreground notification fades on its own when nothing is playing. val sessionActivityIntent = PendingIntent.getActivity( this, @@ -132,13 +129,6 @@ class PlaybackService : MediaSessionService() { super.onDestroy() } - private fun stopSelfWhenIdle() { - val p = mediaSession?.player ?: return - if (p.mediaItemCount == 0 || p.playbackState == Player.STATE_IDLE) { - stopSelf() - } - } - companion object { const val MEDIA_SESSION_ID = "straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 0f4339d6f..cdc2fa39a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -33,11 +33,20 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.PictureInPictureAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -209,10 +218,13 @@ fun PlayerScreen( modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") { + OverlayIconButton(icon = Icons.Filled.Speed, desc = "Playback speed") { showSpeedDialog = true } - OverlayButton(label = if (audioOnly) "📻" else "📺") { + OverlayIconButton( + icon = if (audioOnly) Icons.Filled.Headphones else Icons.Filled.Videocam, + desc = if (audioOnly) "Audio-only on" else "Video on", + ) { audioOnly = !audioOnly controller.trackSelectionParameters = TrackSelectionParameters.Builder(context) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly) @@ -223,7 +235,7 @@ fun PlayerScreen( Toast.LENGTH_SHORT, ).show() } - OverlayButton(label = "↗") { + OverlayIconButton(icon = Icons.Filled.Share, desc = "Share") { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, streamUrl) @@ -231,14 +243,14 @@ fun PlayerScreen( } context.startActivity(Intent.createChooser(send, "Share video")) } - OverlayButton(label = "⊟") { + OverlayIconButton(icon = Icons.Filled.PictureInPictureAlt, desc = "Picture in picture") { if (activity == null) { Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() - return@OverlayButton + return@OverlayIconButton } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() - return@OverlayButton + return@OverlayIconButton } val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(16, 9)) @@ -251,7 +263,9 @@ fun PlayerScreen( Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() } } - OverlayButton(label = "⌄") { onMinimize() } + OverlayIconButton(icon = Icons.Filled.KeyboardArrowDown, desc = "Minimize") { + onMinimize() + } } if (showSpeedDialog) { @@ -271,7 +285,11 @@ fun PlayerScreen( } @Composable -private fun OverlayButton(label: String, onClick: () -> Unit) { +private fun OverlayIconButton( + icon: ImageVector, + desc: String, + onClick: () -> Unit, +) { Box( modifier = Modifier .size(36.dp) @@ -280,7 +298,12 @@ private fun OverlayButton(label: String, onClick: () -> Unit) { .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { - Text(label, color = Color.White, style = MaterialTheme.typography.titleSmall) + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) } } From 35f5affec33d22693d7282c23aac8ad1c6f472ca Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 11:17:20 -0700 Subject: [PATCH 11/87] vc=27: swipe-down on detail page + Background/Popout buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of feedback on vc=26, fixed in one pass: (1) Swipe-to-minimize was on the fullscreen player, where it fought PlayerView's own touch handling and felt janky. Moved the gesture to the VideoDetailScreen — the same place YouTube/NewPipe put it. The drag handle is the inline-player surface itself (the 16:9 box at top); outside that, the description scrolls normally. The whole page translates with the finger via graphicsLayer + an alpha/scale fade so the motion reads as "the video is being tucked away" rather than the old jump-and-snap. Threshold 140dp → onMinimize. Fullscreen keeps the down-arrow button for minimize; the drag-to-dismiss path is gone from PlayerScreen entirely (along with its Animatable + density imports). Whichever surface is visible has exactly one minimize affordance. (2) The minibar was always visible on every non-Player screen, which meant it stacked under the VideoDetail page even though the inline player is already there. Updated visibility predicate to also hide on VideoDetail — the minibar is now strictly the take-over UI for when you leave the video page. Tapping the minibar pushes back to VideoDetail (not fullscreen) so the mental model is symmetrical: swipe-down to leave, tap to come back. (3) Two new buttons on the VideoDetail action row: Background — disables the video track on the controller and pops out of detail. The foreground service keeps audio going; the minibar appears on whatever screen you land on. Pre-checks that the controller is actually playing this video before leaving — otherwise the minibar would dismiss into empty. Popout — enters PiP via the activity, same code path as the fullscreen overlay button. Same controller pre-check. Nav helper: added Navigator.resetTo(Screen) so that minimize from a deep-link entry (where VideoDetail is the only stack item) drops the user on Home rather than no-op'ing. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 11 ++ .../kotlin/com/sulkta/straw/StrawActivity.kt | 14 +- .../straw/feature/detail/VideoDetailScreen.kt | 162 +++++++++++++++++- .../straw/feature/player/PlayerScreen.kt | 50 +----- 5 files changed, 189 insertions(+), 52 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index fdfb000d4..a20dca091 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 26 -const val STRAW_VERSION_NAME = "0.1.0-AL" +const val STRAW_VERSION_CODE = 27 +const val STRAW_VERSION_NAME = "0.1.0-AM" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 9763aa727..1b57d3471 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -42,6 +42,17 @@ class Navigator(initial: Screen) { stack.removeAt(stack.lastIndex) return true } + + /** + * Replace the entire stack with a single screen. Used by the + * swipe-to-minimize gesture when the user lands directly on a video + * page via a deep link — there's nothing to pop back to, so we drop + * them on Home instead. + */ + fun resetTo(s: Screen) { + stack.clear() + stack.add(s) + } } @Composable diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index dea41ffea..2a5cc65e9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -108,13 +108,18 @@ class StrawActivity : ComponentActivity() { Box(modifier = Modifier.fillMaxSize()) { ScreenContent(nav, s = nav.current) - // Persistent minibar — visible on every non-Player - // screen whenever something is loaded. - if (nav.current !is Screen.Player) { + // The minibar is the takeover-when-you-leave UI: + // hide it while you're on the actual video page + // (the inline player IS the player) and hide it + // in fullscreen (which IS the player). Everywhere + // else, audio keeps going and the minibar gives + // you a way back. + val cur = nav.current + if (cur !is Screen.Player && cur !is Screen.VideoDetail) { MinibarOverlay( onExpand = { val item = NowPlaying.current.value ?: return@MinibarOverlay - nav.push(Screen.Player(item.streamUrl, item.title)) + nav.push(Screen.VideoDetail(item.streamUrl, item.title)) }, modifier = Modifier.align(Alignment.BottomCenter), ) @@ -157,6 +162,7 @@ class StrawActivity : ComponentActivity() { streamUrl = s.streamUrl, initialTitle = s.title, onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) }, + onMinimize = { if (!nav.pop()) nav.resetTo(Screen.Home) }, onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 499f232b1..7b552764e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -5,11 +5,19 @@ package com.sulkta.straw.feature.detail +import android.app.Activity +import android.app.PictureInPictureParams import android.content.Intent +import android.os.Build +import android.util.Rational import android.widget.Toast import androidx.annotation.OptIn +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,6 +38,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.PictureInPictureAlt import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip @@ -50,19 +60,25 @@ 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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.UnstableApi import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage @@ -79,28 +95,51 @@ import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml +import kotlinx.coroutines.launch -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, UnstableApi::class) @Composable fun VideoDetailScreen( streamUrl: String, initialTitle: String, onPlay: () -> Unit, + onMinimize: () -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit, vm: VideoDetailViewModel = viewModel(), ) { val state by vm.ui.collectAsStateWithLifecycle() val context = LocalContext.current + val controller = LocalStrawController.current + val activity = context as? Activity var showDownloadDialog by remember { mutableStateOf(false) } var showSaveToPlaylistDialog by remember { mutableStateOf(false) } // Inline-play state resets when navigating to a different video. var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } + // Swipe-down to minimize. The drag handle is the inline player surface + // (the 16:9 box at the top); we translate the WHOLE page with it so the + // motion reads as "the video is being tucked away" rather than "this + // one widget slid." The graphicsLayer + alpha/scale fade keeps it + // smooth — no per-pixel coroutine churn from offset { }. + val density = LocalDensity.current + val dismissThresholdPx = with(density) { 140.dp.toPx() } + val dragY = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + Column( modifier = Modifier .fillMaxSize() + .graphicsLayer { + val y = dragY.value + translationY = y + val p = (y / dismissThresholdPx).coerceIn(0f, 1f) + alpha = 1f - p * 0.4f + val s = 1f - p * 0.08f + scaleX = s + scaleY = s + } .statusBarsPadding() .verticalScroll(rememberScrollState()) .padding(16.dp), @@ -118,6 +157,33 @@ fun VideoDetailScreen( else -> { val d = state.detail ?: return@Column + // Drag-to-minimize gesture lives on the player surface + // itself — same pattern YouTube/NewPipe use. Outside the + // 16:9 box the page scrolls normally, so the drag never + // fights with description scrolling. + val playerDragModifier = Modifier.pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragY.value > dismissThresholdPx) { + onMinimize() + } else { + scope.launch { + dragY.animateTo(0f, spring()) + } + } + }, + onDragCancel = { + scope.launch { dragY.animateTo(0f, spring()) } + }, + onVerticalDrag = { _, dy -> + scope.launch { + dragY.snapTo( + (dragY.value + dy).coerceAtLeast(0f), + ) + } + }, + ) + } if (inlinePlaying) { InlinePlayer( streamUrl = streamUrl, @@ -129,7 +195,8 @@ fun VideoDetailScreen( .fillMaxWidth() .aspectRatio(16f / 9f) .clip(RoundedCornerShape(8.dp)) - .background(Color.Black), + .background(Color.Black) + .then(playerDragModifier), ) } else { Box( @@ -137,7 +204,8 @@ fun VideoDetailScreen( .fillMaxWidth() .aspectRatio(16f / 9f) .clip(RoundedCornerShape(8.dp)) - .clickable { inlinePlaying = true }, + .clickable { inlinePlaying = true } + .then(playerDragModifier), contentAlignment = Alignment.Center, ) { AsyncImage( @@ -219,6 +287,94 @@ fun VideoDetailScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Button(onClick = onPlay) { Text("Play") } + OutlinedButton( + onClick = { + val c = controller + if (c == null) { + Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show() + return@OutlinedButton + } + // Make sure the controller is playing this video + // before backing out — otherwise dropping to the + // minibar would dismiss into an empty slot. + if (NowPlaying.current.value?.streamUrl != streamUrl) { + val r = state.resolved + if (r == null) { + Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() + return@OutlinedButton + } + c.setPlayingFrom( + streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, + resolved = r, + ) + } + // Audio-only: drop video track. Foreground + // service keeps the audio going; minibar takes + // over once we pop off the detail screen. + c.trackSelectionParameters = TrackSelectionParameters.Builder(context) + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) + .build() + if (!c.isPlaying) c.play() + onMinimize() + }, + ) { + Icon( + imageVector = Icons.Filled.Headphones, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Background") + } + OutlinedButton( + onClick = { + if (activity == null) { + Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() + return@OutlinedButton + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() + return@OutlinedButton + } + // PiP needs the controller to actually be playing + // this video, same as Background — otherwise we + // pop out into nothing. + val c = controller + if (c != null && NowPlaying.current.value?.streamUrl != streamUrl) { + val r = state.resolved + if (r != null) { + c.setPlayingFrom( + streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, + resolved = r, + ) + } + } + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .build() + runCatching { activity.enterPictureInPictureMode(params) } + .onSuccess { ok -> + if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show() + } + .onFailure { t -> + Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() + } + }, + ) { + Icon( + imageVector = Icons.Filled.PictureInPictureAlt, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Popout") + } OutlinedButton(onClick = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index cdc2fa39a..4266cf34b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -4,10 +4,12 @@ * * Fullscreen player surface. The player itself lives in PlaybackService * (one ExoPlayer for the whole app); this composable is a thin shell that - * renders a PlayerView bound to the shared MediaController, lets the user - * drag down to dismiss into the minibar, and overlays speed / audio-only - * / share / PiP / minimize controls. SponsorBlock auto-skip lives at the - * activity root in [SponsorBlockSkipLoop]. + * renders a PlayerView bound to the shared MediaController and overlays + * speed / audio-only / share / PiP / minimize controls. To minimize, tap + * the down-arrow button (top right) — the swipe-down gesture lives on + * the VideoDetail page instead, where it doesn't fight PlayerView's own + * touch handling. SponsorBlock auto-skip lives at the activity root in + * [SponsorBlockSkipLoop]. */ package com.sulkta.straw.feature.player @@ -19,17 +21,13 @@ import android.os.Build import android.util.Rational import android.widget.Toast import androidx.annotation.OptIn -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures 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.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -53,16 +51,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -77,9 +71,7 @@ import com.sulkta.straw.OverlayChromeColor import com.sulkta.straw.feature.detail.VideoDetailViewModel import com.sulkta.straw.net.SbSegment import com.sulkta.straw.util.strawLogI -import kotlin.math.roundToInt import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @OptIn(UnstableApi::class) @Composable @@ -98,13 +90,6 @@ fun PlayerScreen( var audioOnly by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } - // Drag-to-minimize: vertical offset accumulated during the gesture. - // On release past the threshold we dismiss into the minibar. - val density = LocalDensity.current - val dismissThresholdPx = with(density) { 200.dp.toPx() } - val dragY = remember { Animatable(0f) } - val scope = rememberCoroutineScope() - // When the resolved playback for this URL is ready, push it into the // shared controller — unless it's already playing this exact URL, in // which case do nothing: the player is already where we want it. The @@ -144,28 +129,7 @@ fun PlayerScreen( val activity = context as? Activity Box( - modifier = Modifier - .fillMaxSize() - .offset { IntOffset(0, dragY.value.roundToInt()) } - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragEnd = { - if (dragY.value > dismissThresholdPx) { - onMinimize() - } else { - scope.launch { dragY.animateTo(0f, tween(180)) } - } - }, - onDragCancel = { - scope.launch { dragY.animateTo(0f, tween(180)) } - }, - onVerticalDrag = { _, dy -> - scope.launch { - dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) - } - }, - ) - }, + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { when { From 2e339814fde397de890cd637fef8ec3fbe44e376 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 11:43:38 -0700 Subject: [PATCH 12/87] vc=28: edge-to-edge player, nav-bar inset, video-track reset, app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layout/playback fixes on vc=26-27 feedback plus the long-deferred app icon. Layout — VideoDetailScreen Player surface now fills the screen width (no 16dp side gutters). Outer Column dropped its 16dp padding; the player Box hangs off the full width with no rounded corners — NewPipe/YouTube look. Everything below (title, chips, button row, description, related list) goes back into an inner Column with 16dp horizontal + 12dp vertical padding so the body still reads correctly. Bottom inset: Spacer(windowInsetsBottomHeight(WindowInsets.navigationBars)) appended at the end of the scrollable column. Last related video can scroll up past the gesture pill / 3-button nav instead of being obscured by it. (Plain navigationBarsPadding would have pushed the whole surface up and left a dead band.) Black-video fix vc=27's Background button disabled the video track on the controller via setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) and that override is sticky. Returning to a video left it audio-only with a black surface. Added a LaunchedEffect(controller, streamUrl) that resets TrackSelectionParameters to defaults on every entry into detail — if the user opened a video page, they want video. The audio-only fullscreen toggle and the Background button still set the override for the duration of that session; they just no longer leak. App icon Replaced the Android default placeholder (sym_def_app_icon, which fdroid was failing to render anyway) with a proper adaptive icon: Background: #166534 deep green (sulkta.com brand) Foreground: tilted lime parallelogram + white play triangle (literal "straw" nod + video-app affordance) Adaptive XML in mipmap-anydpi-v26/. PNG fallbacks rendered via rsvg-convert at all five mipmap densities (mdpi/hdpi/xhdpi/xxhdpi/ xxxhdpi) for pre-API-26 devices. Manifest now points at @mipmap/ic_launcher and @mipmap/ic_launcher_round. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/src/main/AndroidManifest.xml | 4 +- .../straw/feature/detail/VideoDetailScreen.kt | 84 +++++++++++------- .../res/drawable/ic_launcher_background.xml | 5 ++ .../res/drawable/ic_launcher_foreground.xml | 25 ++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 ++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 987 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 987 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 740 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 740 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1355 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 1355 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1809 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 1809 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 2477 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 2477 bytes 17 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 strawApp/src/main/res/drawable/ic_launcher_background.xml create mode 100644 strawApp/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 strawApp/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 strawApp/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 strawApp/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 strawApp/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 strawApp/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 strawApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 strawApp/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index a20dca091..225345b66 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 27 -const val STRAW_VERSION_NAME = "0.1.0-AM" +const val STRAW_VERSION_CODE = 28 +const val STRAW_VERSION_NAME = "0.1.0-AN" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 46abc05b7..87ff57336 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -14,8 +14,8 @@ diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 7b552764e..502314b25 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -25,14 +25,17 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars 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.layout.windowInsetsBottomHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -118,6 +121,16 @@ fun VideoDetailScreen( var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } + // The Background button (and the fullscreen audio-only toggle) + // disable the video track on the shared controller, and that state + // sticks. Entering detail = user wants to watch the video — wipe the + // override and let DASH pick the highest renderable video again. + LaunchedEffect(controller, streamUrl) { + controller?.let { + it.trackSelectionParameters = TrackSelectionParameters.Builder(context).build() + } + } + // Swipe-down to minimize. The drag handle is the inline player surface // (the 16:9 box at the top); we translate the WHOLE page with it so the // motion reads as "the video is being tucked away" rather than "this @@ -127,6 +140,25 @@ fun VideoDetailScreen( val dismissThresholdPx = with(density) { 140.dp.toPx() } val dragY = remember { Animatable(0f) } val scope = rememberCoroutineScope() + val playerDragModifier = Modifier.pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragY.value > dismissThresholdPx) { + onMinimize() + } else { + scope.launch { dragY.animateTo(0f, spring()) } + } + }, + onDragCancel = { + scope.launch { dragY.animateTo(0f, spring()) } + }, + onVerticalDrag = { _, dy -> + scope.launch { + dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) + } + }, + ) + } Column( modifier = Modifier @@ -141,49 +173,27 @@ fun VideoDetailScreen( scaleY = s } .statusBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(16.dp), + .verticalScroll(rememberScrollState()), ) { when { state.loading -> Box( - modifier = Modifier.fillMaxWidth().padding(top = 64.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 64.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } state.error != null -> Text( "error: ${state.error}", color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), ) else -> { val d = state.detail ?: return@Column - // Drag-to-minimize gesture lives on the player surface - // itself — same pattern YouTube/NewPipe use. Outside the - // 16:9 box the page scrolls normally, so the drag never - // fights with description scrolling. - val playerDragModifier = Modifier.pointerInput(Unit) { - detectVerticalDragGestures( - onDragEnd = { - if (dragY.value > dismissThresholdPx) { - onMinimize() - } else { - scope.launch { - dragY.animateTo(0f, spring()) - } - } - }, - onDragCancel = { - scope.launch { dragY.animateTo(0f, spring()) } - }, - onVerticalDrag = { _, dy -> - scope.launch { - dragY.snapTo( - (dragY.value + dy).coerceAtLeast(0f), - ) - } - }, - ) - } + // Player surface — edge-to-edge, NewPipe/YouTube style. + // Lives outside the 16dp horizontal padding so the + // thumbnail fills the screen width with no gutters. if (inlinePlaying) { InlinePlayer( streamUrl = streamUrl, @@ -194,7 +204,6 @@ fun VideoDetailScreen( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(8.dp)) .background(Color.Black) .then(playerDragModifier), ) @@ -203,7 +212,7 @@ fun VideoDetailScreen( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(8.dp)) + .background(Color.Black) .clickable { inlinePlaying = true } .then(playerDragModifier), contentAlignment = Alignment.Center, @@ -229,8 +238,9 @@ fun VideoDetailScreen( } } } - Spacer(modifier = Modifier.height(12.dp)) - + // Everything below the player gets the side gutters + // back; player itself remains edge-to-edge. + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Text( text = d.title, style = MaterialTheme.typography.titleLarge, @@ -492,8 +502,14 @@ fun VideoDetailScreen( }, ) } + } // close inner Column (padded body) } } + // Leave room at the bottom for the system nav bar so the last + // related video doesn't tuck under the gesture pill / 3-button + // nav. Compose's `navigationBarsPadding` would push the whole + // surface up; we want the scroll to extend past it instead. + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } diff --git a/strawApp/src/main/res/drawable/ic_launcher_background.xml b/strawApp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..7d786ca64 --- /dev/null +++ b/strawApp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/strawApp/src/main/res/drawable/ic_launcher_foreground.xml b/strawApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..9bb57a385 --- /dev/null +++ b/strawApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/strawApp/src/main/res/mipmap-hdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f3906f9b9faa3c8659b6a600e32c9e5be5082d GIT binary patch literal 987 zcmV<110?*3P)7l-Tg>9vTe6WkVN8(H0c9I6g?ebxRiL%8zo(_9pIhm} z^X(^j==1UmD17fK@jqfS`T=lZiJ1*c%xqXaup5{izjK|d)=Q~;IrXc- z)?hm~Ow1b#y2kmunX;0Bx3?USnKCjTA zu$BP{(p$_o8FqM_a(DSwo7p@B3DR4POenoJO1pXPqz)3KtC)RgFmNfX(11?Dj6*_a zvC#B5vdJbk*jnX#0V+#ZF>*%fwZW_(@NFI{3zt}QxO2$4qr8^lbKX!||v><6<%mn~r_kBIvFD z+egen4%M?Xzccr z$lWWhi_*_&h*fU~1}uU*^lHp;HOqjf7QQun0GqweF!=aw${Blp^FhbN0774! zD@rE@s6P8bOaNA|kHiF^^!iQ=3L}rWd$N*Rd$RcA%l3-Ke)>oZN=X2h7?MAS>IPOx z0GF7;iH#H-N&>jV2K_GO2KbZ&u!s$Kop$-E!d5Nw_}#PjOCNDWi(nB8&W!i`jS1V9 zmE7j~=TA6trC<>YO?RFGNOxj%GR^ZCmc4%hFXoK}T_KNn*|E9gd7fU+f`@hkFD8~M zd+wLneY#OT0T1m4SWGNczGOboT&24K;92iqo&>f#u~ga9s#R~6cJi=^asZ3zP^wf3 zq?kUXN|iv0g%U9xN|h=B6GI4fPhV1?N|h=B6Z1`m2fTWeDpdj|rd+B#+RZ_gDpdj| zcARC{Oi^yFq~~ix0BnNJE$`))_iO`p??fm-jrysxkqKL>R0;6Pqut=jseCz=ClE0- z>i1t5@lB3Y(?!5irAma_zeSEVxB$t@-P4Z002ov JPDHLkV1jmc(98e; literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f3906f9b9faa3c8659b6a600e32c9e5be5082d GIT binary patch literal 987 zcmV<110?*3P)7l-Tg>9vTe6WkVN8(H0c9I6g?ebxRiL%8zo(_9pIhm} z^X(^j==1UmD17fK@jqfS`T=lZiJ1*c%xqXaup5{izjK|d)=Q~;IrXc- z)?hm~Ow1b#y2kmunX;0Bx3?USnKCjTA zu$BP{(p$_o8FqM_a(DSwo7p@B3DR4POenoJO1pXPqz)3KtC)RgFmNfX(11?Dj6*_a zvC#B5vdJbk*jnX#0V+#ZF>*%fwZW_(@NFI{3zt}QxO2$4qr8^lbKX!||v><6<%mn~r_kBIvFD z+egen4%M?Xzccr z$lWWhi_*_&h*fU~1}uU*^lHp;HOqjf7QQun0GqweF!=aw${Blp^FhbN0774! zD@rE@s6P8bOaNA|kHiF^^!iQ=3L}rWd$N*Rd$RcA%l3-Ke)>oZN=X2h7?MAS>IPOx z0GF7;iH#H-N&>jV2K_GO2KbZ&u!s$Kop$-E!d5Nw_}#PjOCNDWi(nB8&W!i`jS1V9 zmE7j~=TA6trC<>YO?RFGNOxj%GR^ZCmc4%hFXoK}T_KNn*|E9gd7fU+f`@hkFD8~M zd+wLneY#OT0T1m4SWGNczGOboT&24K;92iqo&>f#u~ga9s#R~6cJi=^asZ3zP^wf3 zq?kUXN|iv0g%U9xN|h=B6GI4fPhV1?N|h=B6Z1`m2fTWeDpdj|rd+B#+RZ_gDpdj| zcARC{Oi^yFq~~ix0BnNJE$`))_iO`p??fm-jrysxkqKL>R0;6Pqut=jseCz=ClE0- z>i1t5@lB3Y(?!5irAma_zeSEVxB$t@-P4Z002ov JPDHLkV1jmc(98e; literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-mdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..fcfab8700211002e656f1e323500fa314802beda GIT binary patch literal 740 zcmV^nG9!0O7 z^empd@1Q499Ak$KREA8LAj~gh2r|Kq&SioYOI^FQUDGuwZPzquJOpaZDru6pi--JA z%|m$dj6(B|~0+z1PKXb)z`{fkXWe3Qwj0pKri>Y;K#qGWeqwb)m`woyn-bg@N5(J7& zci#af2=ls~{(ec3Nm2E~sy-lJ^w{zMP>Am;Y61;}AP5nbg7|u)^dqYe2tPAuaoQwB ziri~9czr#(*T+AxSsp!y?c_{2Sy>RnhYs>2o5+iPTrL3M$ zjIoL6%(XjHW0PIp4}W58&)#AF;p@ePwdHz)?Kud6P0QG!#qroa|m|~o?5GHw-@PTo-H3~2dOz2J)MU` z2+IRhfDjW(g#3~sS7S?wygs1*vqNUPRZ^t3(*mz-n1=@HJj`#wd0#*UsPHvi+{tjY zKSy6BOIPy@ERf=FS&_^X$qa%bj^Vz(aUVW5P}ov4%>OHh@jtap43Jz5kX#IqTz&(M WZvJ&>7YL020000^nG9!0O7 z^empd@1Q499Ak$KREA8LAj~gh2r|Kq&SioYOI^FQUDGuwZPzquJOpaZDru6pi--JA z%|m$dj6(B|~0+z1PKXb)z`{fkXWe3Qwj0pKri>Y;K#qGWeqwb)m`woyn-bg@N5(J7& zci#af2=ls~{(ec3Nm2E~sy-lJ^w{zMP>Am;Y61;}AP5nbg7|u)^dqYe2tPAuaoQwB ziri~9czr#(*T+AxSsp!y?c_{2Sy>RnhYs>2o5+iPTrL3M$ zjIoL6%(XjHW0PIp4}W58&)#AF;p@ePwdHz)?Kud6P0QG!#qroa|m|~o?5GHw-@PTo-H3~2dOz2J)MU` z2+IRhfDjW(g#3~sS7S?wygs1*vqNUPRZ^t3(*mz-n1=@HJj`#wd0#*UsPHvi+{tjY zKSy6BOIPy@ERf=FS&_^X$qa%bj^Vz(aUVW5P}ov4%>OHh@jtap43Jz5kX#IqTz&(M WZvJ&>7YL0200000Xz-GbXQ>yrg-Vm!V==>xyy4 z@-mybi}KbaA}5_>8KE4C8p5em$a2?z_ndpqJW!&e4q3D^(i__a@JJWR|fz< zljwr?QfTncR8v;e*sa-kg{X$QIpcwypTQ9~Facl}K*ZyG(%38P;dq#n!Q;q@lAsGP ztS#@wONur;`pR2)&Lx#1EXc&f2#PDChpZ>V5B@Ve4^Vx^_4~D)8}dA$kEHB>+<1e9>(dZk%K`k$d5D2*+=z8msEY} z5XatKCbKkX0#2<14muB2KBT6!KQH?~ZTk8Yg`eSoH0xxpJy)>RCvRP|Yn~B) z(0($#=~~ZeM>%p^4-H{;z7FN|#9t$>W?_FL9L!*JR|-#g5oUnp{giZ*9Dx@8NCIKC zt>|)Lxvps!doIcM8-v^5%1LIRY~HOK%$|3Q^)sPPzK#ol%X^ zOPzVJTTU9N!He3_g4qMU`%nnCV*#{m??28@ADB?((m=REgIhl|=Kh{)3MXWxuJtu? zW9H>w{RnU5^d$)ixaos|qTp=lgpQG5u_~rS2zpP$FWFZBju!bCqs~D{qYivhNVK zx0MCpz3k9=9f6d>_vJ76NaO$?R98Rpm3s07W^uhEp@budu#7QR=7XpoA1<&wgb&hJ z`$Q8FPo+GHF2<#qR2njAnKD1+%Xw{%F51Zjt(#c4rg}_zTazIJ_tw zU|pbenil%&s*ngZ3BRas zX;U$L5qZXS#(jmf|Tpi9;9DTOiCg>CwU z2X~Ens)z{Cz-aCsl+;0>Woj?#Id-zR{=zUeOTLJiNJ5BT+XUa@h<4yZ2s9J|>g+%D zFbBOO(V%WN=DsUWba9~4JU6WTx$YRoKIM!ifT|5EKdUnaGhF7X0uBf43&7`a*XLVhFX3qD zR^{hh4x$le`2F72_mwGePc+ah(=%HK5k-u(G3=fE2id5Xw#KjO48zwG+E=WDY{=EBeCH#BQv%_K>S}{$;dE@O|?^T0}E9u zZ-|fk&op8;-aLY|zNNFwIvbd5Xb#rn(y<-p2E^`vaq=(9Y`_v&I!mK_S)mF#0z@Yg JUg#K1`v-WEY~KI? literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..717e67c8cf3c1db79d750e434ede4f7e94fcde80 GIT binary patch literal 1355 zcmaKseKgYx7{`BDGUGOd9P={w$jhjfHj7?nnlUnY>0Xz-GbXQ>yrg-Vm!V==>xyy4 z@-mybi}KbaA}5_>8KE4C8p5em$a2?z_ndpqJW!&e4q3D^(i__a@JJWR|fz< zljwr?QfTncR8v;e*sa-kg{X$QIpcwypTQ9~Facl}K*ZyG(%38P;dq#n!Q;q@lAsGP ztS#@wONur;`pR2)&Lx#1EXc&f2#PDChpZ>V5B@Ve4^Vx^_4~D)8}dA$kEHB>+<1e9>(dZk%K`k$d5D2*+=z8msEY} z5XatKCbKkX0#2<14muB2KBT6!KQH?~ZTk8Yg`eSoH0xxpJy)>RCvRP|Yn~B) z(0($#=~~ZeM>%p^4-H{;z7FN|#9t$>W?_FL9L!*JR|-#g5oUnp{giZ*9Dx@8NCIKC zt>|)Lxvps!doIcM8-v^5%1LIRY~HOK%$|3Q^)sPPzK#ol%X^ zOPzVJTTU9N!He3_g4qMU`%nnCV*#{m??28@ADB?((m=REgIhl|=Kh{)3MXWxuJtu? zW9H>w{RnU5^d$)ixaos|qTp=lgpQG5u_~rS2zpP$FWFZBju!bCqs~D{qYivhNVK zx0MCpz3k9=9f6d>_vJ76NaO$?R98Rpm3s07W^uhEp@budu#7QR=7XpoA1<&wgb&hJ z`$Q8FPo+GHF2<#qR2njAnKD1+%Xw{%F51Zjt(#c4rg}_zTazIJ_tw zU|pbenil%&s*ngZ3BRas zX;U$L5qZXS#(jmf|Tpi9;9DTOiCg>CwU z2X~Ens)z{Cz-aCsl+;0>Woj?#Id-zR{=zUeOTLJiNJ5BT+XUa@h<4yZ2s9J|>g+%D zFbBOO(V%WN=DsUWba9~4JU6WTx$YRoKIM!ifT|5EKdUnaGhF7X0uBf43&7`a*XLVhFX3qD zR^{hh4x$le`2F72_mwGePc+ah(=%HK5k-u(G3=fE2id5Xw#KjO48zwG+E=WDY{=EBeCH#BQv%_K>S}{$;dE@O|?^T0}E9u zZ-|fk&op8;-aLY|zNNFwIvbd5Xb#rn(y<-p2E^`vaq=(9Y`_v&I!mK_S)mF#0z@Yg JUg#K1`v-WEY~KI? literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..455be2a69ba7453ef56a9f1e6f1f46b9546bb285 GIT binary patch literal 1809 zcmb7Fc{m%`7FR@KYrW1`Dos_HMy;t1QktN)+7hLf1|!9nmYGS=;ZY5mSh`r+YG@*K z(T0qsG`6ZG!U!hHH`J1n7A@6@SfZpU;-xcx%s=ms_rCj``}^+i-gEA`_ndprwE&ct zwuYgGl9H0P_bK;41>XEBAZ0~=!8CbU0cz2{UhYaeU)%lms$vDf^LBSVlS&s0`2?`X z{?5eOcN3`n&ahWq@SXS$|bjxWS%7Qs*-fYFUj}Eu&s&UBkDe?q-4tof~oV=#B1g7{*jx*SB`v!8Z>|T}gFLBxFsdz-B zO|a@#U~x!1e1GAh?VTtNEp|7<%;&S=PHmLeq#$Pf)b!=-o5#ZzhI2CR<)y=` z|5|~YpHHg9IEVSG)AP-S)kM-X+GsbzeO8{Y*Z9(C1Q zY@GP5$cVT;(i_F$tl(S6;ligg7BN#h?sAV8DX9a9tu|v#p5=FX9NEfRilFOaB@r)& z(t=?QrQs*{<;Rs;MbB>6WkkIE`H??byx({?Z-JebN0eWKNzp-}DGlG~?3rvZ>BoUe z#H*eX`CzTc=gQ`XY73pxN>s6_LH(Vu2p>zN6=C~k=1O(Lm}q78hmJeQ6Ghk5O@m?c zzy4cNDAn__w^J9*C;9GGi@EfYF$xw`>4c^yKhowI45ieSC^yTJ-M$*)B;lSE-NtLn5rU6 z$H=&*ve1$zG!=jwkKr41`2fL`^I5iJ!{?lZ12u4<>@!*bkuu6Wm(&Qb(skovvZ&V| zV9&dtdIIW7r>mmP3}|QaS>zd?o`ah61zPH|;|4)T`$#ey$+F+M6-G1+aPdWCN1_~S z(ZWG)Zuqt_4g9!@5TNvj;_cGuVtM;AHY3yr4FkNJzz8m58x(P1k>6nAbZFRiJnI!= zQZw_%$F3Jh9(qs+uS39?8(CXv4l&F&x%<%UUn)_L#_^dN*A+K217bKeN`lLq_db|V zXcuB39j9tVID}=mh4x9ECqgDgt+_i|pqL_IXVSKr6vWT)A(!n#2qcJ9EY8%IDcZwc^Eppc8N`J$}yi`PZ%V2i`L& z64u6_S(^4bYfh;)w<^Uy9q-B5EnpVMRWDiq|N9MYpfPjXQx?4TP>R$|6y|-vR#}u>v^fg(p-wsljQcr z&I-j*7O+(xty?=qe_#(Gzei5B{ms&_{xA8~>j{;aePDVa02lpc`rw4Z!@${QLKp%# ntdd|P@;?86eEk2dOl$!s6W1};0Z$thHBQOf1LgkoB$o6aJD_A_ literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..455be2a69ba7453ef56a9f1e6f1f46b9546bb285 GIT binary patch literal 1809 zcmb7Fc{m%`7FR@KYrW1`Dos_HMy;t1QktN)+7hLf1|!9nmYGS=;ZY5mSh`r+YG@*K z(T0qsG`6ZG!U!hHH`J1n7A@6@SfZpU;-xcx%s=ms_rCj``}^+i-gEA`_ndprwE&ct zwuYgGl9H0P_bK;41>XEBAZ0~=!8CbU0cz2{UhYaeU)%lms$vDf^LBSVlS&s0`2?`X z{?5eOcN3`n&ahWq@SXS$|bjxWS%7Qs*-fYFUj}Eu&s&UBkDe?q-4tof~oV=#B1g7{*jx*SB`v!8Z>|T}gFLBxFsdz-B zO|a@#U~x!1e1GAh?VTtNEp|7<%;&S=PHmLeq#$Pf)b!=-o5#ZzhI2CR<)y=` z|5|~YpHHg9IEVSG)AP-S)kM-X+GsbzeO8{Y*Z9(C1Q zY@GP5$cVT;(i_F$tl(S6;ligg7BN#h?sAV8DX9a9tu|v#p5=FX9NEfRilFOaB@r)& z(t=?QrQs*{<;Rs;MbB>6WkkIE`H??byx({?Z-JebN0eWKNzp-}DGlG~?3rvZ>BoUe z#H*eX`CzTc=gQ`XY73pxN>s6_LH(Vu2p>zN6=C~k=1O(Lm}q78hmJeQ6Ghk5O@m?c zzy4cNDAn__w^J9*C;9GGi@EfYF$xw`>4c^yKhowI45ieSC^yTJ-M$*)B;lSE-NtLn5rU6 z$H=&*ve1$zG!=jwkKr41`2fL`^I5iJ!{?lZ12u4<>@!*bkuu6Wm(&Qb(skovvZ&V| zV9&dtdIIW7r>mmP3}|QaS>zd?o`ah61zPH|;|4)T`$#ey$+F+M6-G1+aPdWCN1_~S z(ZWG)Zuqt_4g9!@5TNvj;_cGuVtM;AHY3yr4FkNJzz8m58x(P1k>6nAbZFRiJnI!= zQZw_%$F3Jh9(qs+uS39?8(CXv4l&F&x%<%UUn)_L#_^dN*A+K217bKeN`lLq_db|V zXcuB39j9tVID}=mh4x9ECqgDgt+_i|pqL_IXVSKr6vWT)A(!n#2qcJ9EY8%IDcZwc^Eppc8N`J$}yi`PZ%V2i`L& z64u6_S(^4bYfh;)w<^Uy9q-B5EnpVMRWDiq|N9MYpfPjXQx?4TP>R$|6y|-vR#}u>v^fg(p-wsljQcr z&I-j*7O+(xty?=qe_#(Gzei5B{ms&_{xA8~>j{;aePDVa02lpc`rw4Z!@${QLKp%# ntdd|P@;?86eEk2dOl$!s6W1};0Z$thHBQOf1LgkoB$o6aJD_A_ literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..edd54e2eb2689cbca5a3480edd8988a69be8cf5e GIT binary patch literal 2477 zcmcImi9gg^6gNW|TO<(~M)rzcmSmkDO~%@gl&ob!6U8eA6EkQ*XtG2jj246B2}AZN z$rdvrGMHpbWQiI(^M2lY|HAv+&%NhXml&*yXQ_uLcja0)FZEGNvz$0ufOg*pw& zH`@~i1y@ePx&tV7dfTE=e7x-i3ZmW6kpO zl`0{=#zaMlK%v?&SQX-b00~IqJZ-QA!LR@IF>mDap4DK4T!1{aa{BHd`ja~3wKyPI zNO4!$OMX5&#ePg(r=1T!f3Uc$*>yd?+PE4Puc{zHijTP*|n>4;@%gs(`gMM%gkj4ZSD4I=cSX1c@fQxU*$_kWb98^`eGL9Y^rf?$%d|b zQy5|>Q-4(IgDTGN3Xk&?-tP}f$|7gB_;tnML<`^;s(Yacbq`Ogr02zjawmBb55y?%>nsSXEqeBw$rp<9We|w7FEE`QyGwG? z`%pETt2VH>-qzxeT|sGhT2~-d3l_I`b-K{eP^);hhdF~3Xi||7Qx$0{${#4RGGKWL zLXNg|U|tY0!zLs+s60_mAe4E$R7k`5)j%O3G9Og2&)(5>D||+V&D;T-BLLu00P&g*8MPm}hY3w`y@jWNCNkh7Lr6Z&E)=@C%<_R1h%EN@^qhM*W}l6x zB}h1^!z&eT*trpuM$LfF=0NtV08`%tTxQK-2I;(sRM%!8Z{<)4r+h(R-HjaL?1?PM zD5C3fCZ0q!=xyFXZjLW2Zq0}fSFJmzxBLn)#ly{!wLQ_X(6YMoE)nx+hHqQk#_Wyq zfv}1iv~f>Jj!0{_zB1|fqb%AN!puS?4#pr5qS5?i!L0@AZsd3p7-G(@*6Ti&_5Fky zR6%jeKBZjUS=oy}j^g{(*K}VdDqLalhBjD?Lq%t>Cx-}$yP-9W-gtr|h3G!GhMmHu zB%=O3?Q>TlAs3~sEe~8XZH7TY-cplu-N;8R0J|`q?uFSF$p=)%mDv0q~m-B}#Ph?LMk3pcmBC4ugml7>Fb=Hkhp8s`{@>(ZSKZE9P{k zAs)wT_~tTIG3(gQ&36Fo@+(-{{T&c7;gRlA1Y%hz69m=P--8dsTH%t%3#FsWo?|tOXASQA zOQF?;DExBO`f_sj@kG&^MtzXf!TjTT;a2+{5OOxs zmL(g91gBhnqmOi?2MiB(S`S3&{7QYn8(qd5gP~4+2wRUxu7h~Ennp5K$aw>VcL7HG z!^)gq&Sg}SZI4~w2^L0>zcR#?+?r-xr@8s08+Odi=xf0d>odTI%hJ@DE@s-T+mJ$# z#@xqr-Gu*WEUzM}p`R*(iVGw)|M(oDA{sY7N5ykbbHkkSvo*!oP~oJeyEGlJF67zw zG%Zf`UJ}u${log^uR2_2)kaUWJEYLYH)mzJ9*ZWlQ({$`>Z4-RtNpc*+%Yfr*WY=0?*eXDaLjNtnx!AHC;CxEs_7lbq|yAaDwOA>{IG=zVNUUMw2BLc=wXB!2j5@XTVydrPZ*^Seh zdq()D-}hlxW;BgQ_-$Bw7?EZJMlYvY=(?)k$ivDF>*cK96EbM(bTdvlE$%_Uv+DJQ z@^;&ZwU~!LsqL5bGbhA~>z-WQysDaYFG;F7A70fyvB}XDb0EE24~WLzRs?s34jDuj%-& cpV<(feB~KiITJAk_FsI~Cr_dNvbYfQHxo3P)&Kwi literal 0 HcmV?d00001 diff --git a/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..edd54e2eb2689cbca5a3480edd8988a69be8cf5e GIT binary patch literal 2477 zcmcImi9gg^6gNW|TO<(~M)rzcmSmkDO~%@gl&ob!6U8eA6EkQ*XtG2jj246B2}AZN z$rdvrGMHpbWQiI(^M2lY|HAv+&%NhXml&*yXQ_uLcja0)FZEGNvz$0ufOg*pw& zH`@~i1y@ePx&tV7dfTE=e7x-i3ZmW6kpO zl`0{=#zaMlK%v?&SQX-b00~IqJZ-QA!LR@IF>mDap4DK4T!1{aa{BHd`ja~3wKyPI zNO4!$OMX5&#ePg(r=1T!f3Uc$*>yd?+PE4Puc{zHijTP*|n>4;@%gs(`gMM%gkj4ZSD4I=cSX1c@fQxU*$_kWb98^`eGL9Y^rf?$%d|b zQy5|>Q-4(IgDTGN3Xk&?-tP}f$|7gB_;tnML<`^;s(Yacbq`Ogr02zjawmBb55y?%>nsSXEqeBw$rp<9We|w7FEE`QyGwG? z`%pETt2VH>-qzxeT|sGhT2~-d3l_I`b-K{eP^);hhdF~3Xi||7Qx$0{${#4RGGKWL zLXNg|U|tY0!zLs+s60_mAe4E$R7k`5)j%O3G9Og2&)(5>D||+V&D;T-BLLu00P&g*8MPm}hY3w`y@jWNCNkh7Lr6Z&E)=@C%<_R1h%EN@^qhM*W}l6x zB}h1^!z&eT*trpuM$LfF=0NtV08`%tTxQK-2I;(sRM%!8Z{<)4r+h(R-HjaL?1?PM zD5C3fCZ0q!=xyFXZjLW2Zq0}fSFJmzxBLn)#ly{!wLQ_X(6YMoE)nx+hHqQk#_Wyq zfv}1iv~f>Jj!0{_zB1|fqb%AN!puS?4#pr5qS5?i!L0@AZsd3p7-G(@*6Ti&_5Fky zR6%jeKBZjUS=oy}j^g{(*K}VdDqLalhBjD?Lq%t>Cx-}$yP-9W-gtr|h3G!GhMmHu zB%=O3?Q>TlAs3~sEe~8XZH7TY-cplu-N;8R0J|`q?uFSF$p=)%mDv0q~m-B}#Ph?LMk3pcmBC4ugml7>Fb=Hkhp8s`{@>(ZSKZE9P{k zAs)wT_~tTIG3(gQ&36Fo@+(-{{T&c7;gRlA1Y%hz69m=P--8dsTH%t%3#FsWo?|tOXASQA zOQF?;DExBO`f_sj@kG&^MtzXf!TjTT;a2+{5OOxs zmL(g91gBhnqmOi?2MiB(S`S3&{7QYn8(qd5gP~4+2wRUxu7h~Ennp5K$aw>VcL7HG z!^)gq&Sg}SZI4~w2^L0>zcR#?+?r-xr@8s08+Odi=xf0d>odTI%hJ@DE@s-T+mJ$# z#@xqr-Gu*WEUzM}p`R*(iVGw)|M(oDA{sY7N5ykbbHkkSvo*!oP~oJeyEGlJF67zw zG%Zf`UJ}u${log^uR2_2)kaUWJEYLYH)mzJ9*ZWlQ({$`>Z4-RtNpc*+%Yfr*WY=0?*eXDaLjNtnx!AHC;CxEs_7lbq|yAaDwOA>{IG=zVNUUMw2BLc=wXB!2j5@XTVydrPZ*^Seh zdq()D-}hlxW;BgQ_-$Bw7?EZJMlYvY=(?)k$ivDF>*cK96EbM(bTdvlE$%_Uv+DJQ z@^;&ZwU~!LsqL5bGbhA~>z-WQysDaYFG;F7A70fyvB}XDb0EE24~WLzRs?s34jDuj%-& cpV<(feB~KiITJAk_FsI~Cr_dNvbYfQHxo3P)&Kwi literal 0 HcmV?d00001 From 29ffed265b3d81f0154040480a38a284f0329c4e Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 11:54:45 -0700 Subject: [PATCH 13/87] vc=29: fullscreen overlay controls respect display cutout + status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In portrait fullscreen, the camera notch / cutout was eating the top overlay buttons — SB pill, Speed, Headphones, Videocam, Share, PiP, Minimize. Tappable area sat under the cutout shadow; presses dropped. Wrapped the overlay layer (SB pill + control Row) in a Box with windowInsetsPadding(WindowInsets.safeDrawing). safeDrawing is the union of system bars + display cutouts + IME, so this single modifier covers both portrait (notch at top) and landscape (cutout at side) without per-orientation logic. The PlayerView itself still uses fillMaxSize with no inset padding — video stays full-bleed and reads as immersive; only the touch-targets respect the safe area. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../sulkta/straw/feature/player/PlayerScreen.kt | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 225345b66..83175d02a 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 28 -const val STRAW_VERSION_NAME = "0.1.0-AN" +const val STRAW_VERSION_CODE = 29 +const val STRAW_VERSION_NAME = "0.1.0-AO" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 4266cf34b..0c3ee8f01 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -27,9 +27,12 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -153,6 +156,8 @@ fun PlayerScreen( ) else -> { + // Video surface — bleeds full-screen including under any + // display cutout / camera notch. Looks more immersive. AndroidView( factory = { ctx -> PlayerView(ctx).apply { @@ -164,6 +169,15 @@ fun PlayerScreen( onRelease = { it.player = null }, modifier = Modifier.fillMaxSize(), ) + // Overlay controls layer — sits inside the safe area so + // buttons don't get eaten by the notch in portrait or by + // a side cutout in landscape. SafeDrawing covers system + // bars + display cutouts in one go. + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { Box( modifier = Modifier .align(Alignment.TopStart) @@ -231,6 +245,7 @@ fun PlayerScreen( onMinimize() } } + } // close safe-area overlay Box if (showSpeedDialog) { SpeedPickerDialog( From 20ee8023c11b3178c71104326f2840155cfb84b2 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 11:58:43 -0700 Subject: [PATCH 14/87] vc=30: minibar above nav buttons + simpler icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on vc=29 (the cutout fix is bundled in too — vc=29 never shipped standalone): Minibar Was rendering UNDER the system nav buttons / gesture pill because StrawActivity uses enableEdgeToEdge() and the minibar was aligned BottomCenter with no inset awareness. Added navigationBarsPadding() to the MinibarOverlay Column so it lifts above the system bar in both gesture-nav and 3-button-nav modes. Icon v1 had two overlapping shapes (tilted lime parallelogram + white play triangle) that fought each other and read as visual noise at launcher size. Replaced with a single bold white play triangle on the deep-green (#166534) background — one strong silhouette, reads at any size, says "video app" without ceremony. PNG fallbacks re-rendered at all five mipmap densities. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../straw/feature/player/MinibarOverlay.kt | 7 +++++- .../res/drawable/ic_launcher_foreground.xml | 20 ++++++------------ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 987 -> 603 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 987 -> 603 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 740 -> 479 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 740 -> 479 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 1355 -> 759 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 1355 -> 759 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1809 -> 973 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 1809 -> 973 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 2477 -> 1200 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 2477 -> 1200 bytes 13 files changed, 14 insertions(+), 17 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 83175d02a..021a8e0ff 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 29 -const val STRAW_VERSION_NAME = "0.1.0-AO" +const val STRAW_VERSION_CODE = 30 +const val STRAW_VERSION_NAME = "0.1.0-AP" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt index dea622a58..b9dbdcda6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.width @@ -79,7 +80,11 @@ fun MinibarOverlay( onDispose { controller.removeListener(listener) } } - Column(modifier = modifier.fillMaxWidth()) { + // navigationBarsPadding shifts the whole minibar up by the system + // nav-bar height so the bar sits ABOVE the gesture pill / 3-button + // nav, not behind them. enableEdgeToEdge in StrawActivity means + // anything aligned BottomCenter lands under those buttons otherwise. + Column(modifier = modifier.fillMaxWidth().navigationBarsPadding()) { HorizontalDivider() Surface( color = MaterialTheme.colorScheme.surfaceVariant, diff --git a/strawApp/src/main/res/drawable/ic_launcher_foreground.xml b/strawApp/src/main/res/drawable/ic_launcher_foreground.xml index 9bb57a385..7dac7e8a3 100644 --- a/strawApp/src/main/res/drawable/ic_launcher_foreground.xml +++ b/strawApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,25 +1,17 @@ - - - + android:pathData="M 38,30 L 38,78 L 82,54 Z" /> diff --git a/strawApp/src/main/res/mipmap-hdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-hdpi/ic_launcher.png index c8f3906f9b9faa3c8659b6a600e32c9e5be5082d..c14f88f15a69484dd10899f061a8dd70d63367c5 100644 GIT binary patch delta 561 zcmV-10?z&02ipXYHGcvSNklz4Mi+GHZ~*6Sy@<3v9KCh z$pQ=8A!4RxGf^HXuf;6P6h`vMBhfUT3kw#*Mo}Y=JfalCjKwWk;L4nH|9f=4{XPHx z-R}S0ySS3Ep*-O~q7%IVELe;hEJh6$qXvsngT<)9V$@(UYJad8HCT)qkYe4nt#y{_ zM4cvX7UU*O2BX_%U+?f*&6R*-{|5ZsEzT(%X>?7t4wmJWfDivYSdWdRv|^>rGt%hF z&(1|!;%5-ggCt2-bLD2&q}yiCN;lvw^d;yAU7w_{vs7<6rdq1%Q}oH$%YC3y!7|e_ z+H0D3oHMFrQ-9So=NOD`n|-6xr$RObymN|71uA4y*vk|`k}I^6)_pWY-&qP1|*wW6qAWFo7xqNlO)j__?AFu&oSM9aCY>!%e5$W ze178a_pcrV;~AnYvGcpDo`ung;Otv88s{jjh}}Ou41X+nhS$8$kr$AG;>9Ab5#QFl zYj)`7@eXjTNU>NfHn+d#^bed~hrx#n6AOg)x~KaN&yJDi3lIZ|o+7G>g>Nr=7so)N zr-({o;L%eAT?{FDiXe)iMo$qeG3@9m0xzac^b~;<3*TNejd%EWm!mQC=qV!cKf%Oc zF>0_FH7Zz)8Z1T)7NZ7>QG>;(!D7^4F>1U6qFTbtHM%BP00000NkvXXu0mjf%$5t4 delta 948 zcmV;l155nd1ltFYHGcz;Nkl`@tm>94A z2i`nr;vcY!2X7uc7|+J|a`j~NvWv#VOmADv;@DfVkvU;ZkkSEV8!v@=Xwp@nwXwga zrKX=->BIBwCwb`e@(U<@?<(;>Vl(;yaAAp=4NJ^ySYl?w5`QxrmYCVF8<-uxbDgW! zOR0Q0^{c_wU^_QV%o_{3#{vXF_^DBXAZm2=XgA01(`<&}TW#(A8zvS?Oh_-Cq$fgB zk!uuJYgYCPh1E3sgVxf&F~2dJ!yb%}^}Jte2#w$WH-b}9n?rW6wCc^$ zPEKQlF=FIwOn>2SaUmunX;0Bx z3?USnKCjTAu$BP{(p$_o8FqM_a(DSwo7p@B3DR4POenoJO1pXPqz)3KtC)RgFmNfX z(11?Dj6*_avC#B5vdJbk*jnX#0V+#ZF>*%fwZW_(@PBO{DhrobbhvZKxue6buutJD z#SMVzO9YqL!&?iB_g~$J&x@asiE)MYY=${4RskOX<4~NZX5TD474&TObHnkdLgQjG z4V#XA+al<#{@i|xzJ5F1>)EE=jQ9MF3EP&H+~)e{PdIX=U=a&Vcb)=BcVcuh&GQ(Ry?+8P=8XkiA&+?3vAN`V zo?g#_hjs%mCYCCD?w8qpx=}s>5A6n6Oe|HtWIoVbrMm&(S?^z-1hzY|RN2$2Rd1Gd z@_(?2asZ3zP^wf3q?kUXN|iv0g%U9xN|h=B6GI4fPhV1?N|h=B6Z1`m2fTWeDpdj| zrd+B#+RZ_gDpdj|cARC{Oi^yFq~~ix0BnNJE$`))_iO`p??fm-jrysxkqKL>R0;6P zqut=jseCz=ClE0->i1t5@lB3Y(?!5ir7cR3_1_kgVTqXyOU!IoVrIhz4Mi+GHZ~*6Sy@<3v9KCh z$pQ=8A!4RxGf^HXuf;6P6h`vMBhfUT3kw#*Mo}Y=JfalCjKwWk;L4nH|9f=4{XPHx z-R}S0ySS3Ep*-O~q7%IVELe;hEJh6$qXvsngT<)9V$@(UYJad8HCT)qkYe4nt#y{_ zM4cvX7UU*O2BX_%U+?f*&6R*-{|5ZsEzT(%X>?7t4wmJWfDivYSdWdRv|^>rGt%hF z&(1|!;%5-ggCt2-bLD2&q}yiCN;lvw^d;yAU7w_{vs7<6rdq1%Q}oH$%YC3y!7|e_ z+H0D3oHMFrQ-9So=NOD`n|-6xr$RObymN|71uA4y*vk|`k}I^6)_pWY-&qP1|*wW6qAWFo7xqNlO)j__?AFu&oSM9aCY>!%e5$W ze178a_pcrV;~AnYvGcpDo`ung;Otv88s{jjh}}Ou41X+nhS$8$kr$AG;>9Ab5#QFl zYj)`7@eXjTNU>NfHn+d#^bed~hrx#n6AOg)x~KaN&yJDi3lIZ|o+7G>g>Nr=7so)N zr-({o;L%eAT?{FDiXe)iMo$qeG3@9m0xzac^b~;<3*TNejd%EWm!mQC=qV!cKf%Oc zF>0_FH7Zz)8Z1T)7NZ7>QG>;(!D7^4F>1U6qFTbtHM%BP00000NkvXXu0mjf%$5t4 delta 948 zcmV;l155nd1ltFYHGcz;Nkl`@tm>94A z2i`nr;vcY!2X7uc7|+J|a`j~NvWv#VOmADv;@DfVkvU;ZkkSEV8!v@=Xwp@nwXwga zrKX=->BIBwCwb`e@(U<@?<(;>Vl(;yaAAp=4NJ^ySYl?w5`QxrmYCVF8<-uxbDgW! zOR0Q0^{c_wU^_QV%o_{3#{vXF_^DBXAZm2=XgA01(`<&}TW#(A8zvS?Oh_-Cq$fgB zk!uuJYgYCPh1E3sgVxf&F~2dJ!yb%}^}Jte2#w$WH-b}9n?rW6wCc^$ zPEKQlF=FIwOn>2SaUmunX;0Bx z3?USnKCjTAu$BP{(p$_o8FqM_a(DSwo7p@B3DR4POenoJO1pXPqz)3KtC)RgFmNfX z(11?Dj6*_avC#B5vdJbk*jnX#0V+#ZF>*%fwZW_(@PBO{DhrobbhvZKxue6buutJD z#SMVzO9YqL!&?iB_g~$J&x@asiE)MYY=${4RskOX<4~NZX5TD474&TObHnkdLgQjG z4V#XA+al<#{@i|xzJ5F1>)EE=jQ9MF3EP&H+~)e{PdIX=U=a&Vcb)=BcVcuh&GQ(Ry?+8P=8XkiA&+?3vAN`V zo?g#_hjs%mCYCCD?w8qpx=}s>5A6n6Oe|HtWIoVbrMm&(S?^z-1hzY|RN2$2Rd1Gd z@_(?2asZ3zP^wf3q?kUXN|iv0g%U9xN|h=B6GI4fPhV1?N|h=B6Z1`m2fTWeDpdj| zrd+B#+RZ_gDpdj|cARC{Oi^yFq~~ix0BnNJE$`))_iO`p??fm-jrysxkqKL>R0;6P zqut=jseCz=ClE0->i1t5@lB3Y(?!5ir7cR3_1_kgVTqXyOU!IoVrIhXaZHGct=NklI*1S$br2yi>L5a3 z)Io&6(07mkH=nVEnLIEz`)FHEJczAAs+s%74uwlt?$2j^7!pDsym3xC^Jyd zT30-Mb>UPk8P!4IAPBJ2G}~LdWOjPHjIiXOa1a9n0}~^YpMSB-k;YXuK{3817tbIg8`8F!!T;@Ot1Kbm;o2 z`qdMzJ-JQ17Jphf=)!|*jcX?zyLpyWjnsG0lef=1H_cpqV%z`!|H;r!O$WXI`f=*c z#WVLT|Mlk=*@jTZLEnGg|I=h#=Ohgbcjt=P&04<{qA_PVqLQoQH#kHAVw|%mafr1bH#7_DTqsdZ_^?Y;}6?x3gp4v<0K zNI+T=1d2>|-vK5F^SYh>eo2u@QT4;BJ|JK8*zy2Si0>+D0u6*92oaWo_#(*Tz||e$f@YilX)oYIK6Q5UTe$i z4-zA8d8MqLPmHmN=*+b{Q)81|-Vc9bY|q|d{^9Gzg|+2+gY7v8flbj0vUyw`>jzBn z@!Kb}@1829Xaza_xTI3zE3+S-Jy@PsN6`c_+pTu5yPfia{BR?8a=fd>ExO=ZJ8OptR3cMP6dt8BLy>13WQ zA87}vIT$^iheHU<15|(z6H0{qk|I}QONqQbp#HN%X1i5Vq_)!nuWXoy2I@S_Z^3zA zKn1AqHC^1vaJ4^2UnNUd^9(GI;%`}z%oNECf;1wI;l93cA3iov*itjh|0{^`KebE@ hkX#IqTnvz0eglne{&i><2#o*$002ovPDHLkV1jZSOK1Q9 diff --git a/strawApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-mdpi/ic_launcher_round.png index fcfab8700211002e656f1e323500fa314802beda..7885352b044bac81fa38aa09f9785e8d96cde23e 100644 GIT binary patch delta 436 zcmV;l0Zabm1>XaZHGct=NklI*1S$br2yi>L5a3 z)Io&6(07mkH=nVEnLIEz`)FHEJczAAs+s%74uwlt?$2j^7!pDsym3xC^Jyd zT30-Mb>UPk8P!4IAPBJ2G}~LdWOjPHjIiXOa1a9n0}~^YpMSB-k;YXuK{3817tbIg8`8F!!T;@Ot1Kbm;o2 z`qdMzJ-JQ17Jphf=)!|*jcX?zyLpyWjnsG0lef=1H_cpqV%z`!|H;r!O$WXI`f=*c z#WVLT|Mlk=*@jTZLEnGg|I=h#=Ohgbcjt=P&04<{qA_PVqLQoQH#kHAVw|%mafr1bH#7_DTqsdZ_^?Y;}6?x3gp4v<0K zNI+T=1d2>|-vK5F^SYh>eo2u@QT4;BJ|JK8*zy2Si0>+D0u6*92oaWo_#(*Tz||e$f@YilX)oYIK6Q5UTe$i z4-zA8d8MqLPmHmN=*+b{Q)81|-Vc9bY|q|d{^9Gzg|+2+gY7v8flbj0vUyw`>jzBn z@!Kb}@1829Xaza_xTI3zE3+S-Jy@PsN6`c_+pTu5yPfia{BR?8a=fd>ExO=ZJ8OptR3cMP6dt8BLy>13WQ zA87}vIT$^iheHU<15|(z6H0{qk|I}QONqQbp#HN%X1i5Vq_)!nuWXoy2I@S_Z^3zA zKn1AqHC^1vaJ4^2UnNUd^9(GI;%`}z%oNECf;1wI;l93cA3iov*itjh|0{^`KebE@ hkX#IqTnvz0eglne{&i><2#o*$002ovPDHLkV1jZSOK1Q9 diff --git a/strawApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-xhdpi/ic_launcher.png index 717e67c8cf3c1db79d750e434ede4f7e94fcde80..d5571af16ee797b9dfd6fe31b26d5b1e2b76de79 100644 GIT binary patch literal 759 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1SE5RJ;(=AY)RhkE)4%caKYZ?lNlJ8)_A%& zhE&XXd*`6{xO36gf14te$1e}4T~eY1Ls-YunHl2`6*F|U7j?90=Qi+}Tb-uo*rXSZbY%xRC7 z{`q_F_RKGGKVSbpb-Qb;d`|7f_!FgHmj?IlyZ12v$DE(Kr61pIJGb*-d$`|Mwb#-% z`%cRL_%C;5gKpuz$8TmIbhkZzUSp2s|HhtUZ>`z=;sY#-qnISQ(*42}oUfkAU$>hXHH`#OC3>uX9&lpZ)3 zb8o)R<=h=_XT4V_Wx>y*<$s0CpWE#b-60yG@LHLbRrth34@X9pBCDVYuN;_^Szq$1 z7za%_<}eA! z6zLOD5%GBI;b_YA|B3EYkGzmmOXXj$gXt5i^&y)c+6bfGL^5)78&qol`;+0A4OV`v3p{ delta 1319 zcmaJ>Yc$gf0RPL#Ot&fIG>^GQ9wRMn7Cq*#S*DOj_qya2#)P=!5%XsrL&c(X#W-Vm z%x3PQyqZMhq?0ToltWQNIF<6qa(%n!+zSs&7|0k1ILPEUx9+G2w>EI;bLokk8JJy6wJmR4{wtl58jn)TH>quh5LM`2 z2M^2mjT^QNlcG;r&nDKLYq%{C&Vkot)lby^-k;HV`37YPBK}4`n8NBP6P@%RPXY`3 zXvszyLQT@)81hncUTSWMj&VC@I?j8QMeUR(`ItzR^D+za`^P9u#KNy!Ar=0utC<^) zDe$BwTz~h$pwi&A_6+cWJcju?Z z^xxz4A!I0Vxx0=#^{LfD-pjWXg}P);WtF;vf2yBQC;`ALuMxw&i&>7KYoo==7np14 zl~r!TNS;kk6}Nx$)>O=t7iQsZ4bjhA%Xl!jJRqk=;Oj;q^l;GTI$vC1v6$U9aXB@` zOH5m^q$vzYzXy>wH+Kl(dpH+ow1qO7z*{iuB~<_dSar?dclwFrgt?X0m;$aa)I8F3 zhX6+Z{CJk_CVG^-)GZzjeJ*J{6r6K2_GimONu$ii zxFaQ}*H?7z)uB0AFZ&X$%{bRv;3;kT$is?Fcj2L^;Ui~chRcMl{r*DP#B6+pYjCNYSvDk zbhjvTd}%orqb3#Pb)LIr%sPza@n<=AJR+xl>=;*^3GiQ!`=*qAJICG^ zX>F$}vt+0B^VgXRzQg-buqg0URj?mE-(P8l7hLjEXP97@aCR4fs|qeTr#+0)U*M?% z_6O{8;OD`cGfnc>XgqSG>}w_$Q-{+3@nGY};<%(U0%(xynykPeikP)LOdJwdOIJ@} z=15Pml`TPiKJglRof*2<3xXUd$jqU`XU*UjvD<~az)148!^p|s39GB$ll2g-MEWRx zaYzExO36gf14te$1e}4T~eY1Ls-YunHl2`6*F|U7j?90=Qi+}Tb-uo*rXSZbY%xRC7 z{`q_F_RKGGKVSbpb-Qb;d`|7f_!FgHmj?IlyZ12v$DE(Kr61pIJGb*-d$`|Mwb#-% z`%cRL_%C;5gKpuz$8TmIbhkZzUSp2s|HhtUZ>`z=;sY#-qnISQ(*42}oUfkAU$>hXHH`#OC3>uX9&lpZ)3 zb8o)R<=h=_XT4V_Wx>y*<$s0CpWE#b-60yG@LHLbRrth34@X9pBCDVYuN;_^Szq$1 z7za%_<}eA! z6zLOD5%GBI;b_YA|B3EYkGzmmOXXj$gXt5i^&y)c+6bfGL^5)78&qol`;+0A4OV`v3p{ delta 1319 zcmaJ>Yc$gf0RPL#Ot&fIG>^GQ9wRMn7Cq*#S*DOj_qya2#)P=!5%XsrL&c(X#W-Vm z%x3PQyqZMhq?0ToltWQNIF<6qa(%n!+zSs&7|0k1ILPEUx9+G2w>EI;bLokk8JJy6wJmR4{wtl58jn)TH>quh5LM`2 z2M^2mjT^QNlcG;r&nDKLYq%{C&Vkot)lby^-k;HV`37YPBK}4`n8NBP6P@%RPXY`3 zXvszyLQT@)81hncUTSWMj&VC@I?j8QMeUR(`ItzR^D+za`^P9u#KNy!Ar=0utC<^) zDe$BwTz~h$pwi&A_6+cWJcju?Z z^xxz4A!I0Vxx0=#^{LfD-pjWXg}P);WtF;vf2yBQC;`ALuMxw&i&>7KYoo==7np14 zl~r!TNS;kk6}Nx$)>O=t7iQsZ4bjhA%Xl!jJRqk=;Oj;q^l;GTI$vC1v6$U9aXB@` zOH5m^q$vzYzXy>wH+Kl(dpH+ow1qO7z*{iuB~<_dSar?dclwFrgt?X0m;$aa)I8F3 zhX6+Z{CJk_CVG^-)GZzjeJ*J{6r6K2_GimONu$ii zxFaQ}*H?7z)uB0AFZ&X$%{bRv;3;kT$is?Fcj2L^;Ui~chRcMl{r*DP#B6+pYjCNYSvDk zbhjvTd}%orqb3#Pb)LIr%sPza@n<=AJR+xl>=;*^3GiQ!`=*qAJICG^ zX>F$}vt+0B^VgXRzQg-buqg0URj?mE-(P8l7hLjEXP97@aCR4fs|qeTr#+0)U*M?% z_6O{8;OD`cGfnc>XgqSG>}w_$Q-{+3@nGY};<%(U0%(xynykPeikP)LOdJwdOIJ@} z=15Pml`TPiKJglRof*2<3xXUd$jqU`XU*UjvD<~az)148!^p|s39GB$ll2g-MEWRx zaYzE<|{ zLn`LHy}jT2YM{)qkMBd*y9KCBoZuMfY(I@-id(eI9G2Nr)e2WGyt+t3e}_k4l%rUq z3)eEmsV$1FGg_USd7lZXXoO5jQn;y`YtgKD?WNb9;(O0+*4IC%+4p^~&1v7MreSJ_ z0w4ThIN95DP(z}JcEs|7S$#6utMl*eTYBZI;=PY15rv7n7Ou)$^Yq`!S;Dg~sjfT! zv19qcp4{2n=h@bMN?o>p;i`2T4!jQj%=STh%JgYlubSK2-w%!seEUo6JHx@Azt-Q& z%(i};x38??yW{okJ6br?LS(J1N|vqOyt4FsU+lX4LS``?&q*O#q0xbVA35dEF`guT z#zWlIN_pkM9**=B4tdF|Jv@pt1W)$xsLv4m)5D`1A@r+<$6&_C-#t2>;=Aph@8>j| zk(l;m^_k{`v=ol}38I&JdX#1e9_#7RnBh2o;zd^P2gXLn64*-Ij!B<6aMt5MXC=d| ziCo*b43$N5bkCux#Y@(`X|qOLJYX?r!D!-EpVo%rfl8qpFJTPWMnk%Kd*7q zia2-YoCoV|j&|dqgJ$PW0+r5Cy3XE^TAxyVOl~XRogaTn_450*Zn&*kv&N>zcFxQ9 z75_8U&iiXeoO4qOkCV*UddgVcX6lc%4@wRY21jnkZcU&+Q!$ee8KDBwIXY#wIVrF`9YjX zN0tj_h*lficUus;=inci#xO&+&y_4uuHt7t10@0tj&N-0Qj*AEiDH%GF=txa#LFhz z7{%s9#|_Kjmp z6RY%0nf#a8^Jmn({w0uZa8L>8o`!_LUI*><&8`+qYi)SH>FzG6eerJULF>=m-0Si` vdaqiu@zPh`Nz8v2^1B^O5aFR6@l3qs-n7*JJ!USz{K??y>gTe~DWM4fA5*B* literal 1809 zcmb7Fc{m%`7FR@KYrW1`Dos_HMy;t1QktN)+7hLf1|!9nmYGS=;ZY5mSh`r+YG@*K z(T0qsG`6ZG!U!hHH`J1n7A@6@SfZpU;-xcx%s=ms_rCj``}^+i-gEA`_ndprwE&ct zwuYgGl9H0P_bK;41>XEBAZ0~=!8CbU0cz2{UhYaeU)%lms$vDf^LBSVlS&s0`2?`X z{?5eOcN3`n&ahWq@SXS$|bjxWS%7Qs*-fYFUj}Eu&s&UBkDe?q-4tof~oV=#B1g7{*jx*SB`v!8Z>|T}gFLBxFsdz-B zO|a@#U~x!1e1GAh?VTtNEp|7<%;&S=PHmLeq#$Pf)b!=-o5#ZzhI2CR<)y=` z|5|~YpHHg9IEVSG)AP-S)kM-X+GsbzeO8{Y*Z9(C1Q zY@GP5$cVT;(i_F$tl(S6;ligg7BN#h?sAV8DX9a9tu|v#p5=FX9NEfRilFOaB@r)& z(t=?QrQs*{<;Rs;MbB>6WkkIE`H??byx({?Z-JebN0eWKNzp-}DGlG~?3rvZ>BoUe z#H*eX`CzTc=gQ`XY73pxN>s6_LH(Vu2p>zN6=C~k=1O(Lm}q78hmJeQ6Ghk5O@m?c zzy4cNDAn__w^J9*C;9GGi@EfYF$xw`>4c^yKhowI45ieSC^yTJ-M$*)B;lSE-NtLn5rU6 z$H=&*ve1$zG!=jwkKr41`2fL`^I5iJ!{?lZ12u4<>@!*bkuu6Wm(&Qb(skovvZ&V| zV9&dtdIIW7r>mmP3}|QaS>zd?o`ah61zPH|;|4)T`$#ey$+F+M6-G1+aPdWCN1_~S z(ZWG)Zuqt_4g9!@5TNvj;_cGuVtM;AHY3yr4FkNJzz8m58x(P1k>6nAbZFRiJnI!= zQZw_%$F3Jh9(qs+uS39?8(CXv4l&F&x%<%UUn)_L#_^dN*A+K217bKeN`lLq_db|V zXcuB39j9tVID}=mh4x9ECqgDgt+_i|pqL_IXVSKr6vWT)A(!n#2qcJ9EY8%IDcZwc^Eppc8N`J$}yi`PZ%V2i`L& z64u6_S(^4bYfh;)w<^Uy9q-B5EnpVMRWDiq|N9MYpfPjXQx?4TP>R$|6y|-vR#}u>v^fg(p-wsljQcr z&I-j*7O+(xty?=qe_#(Gzei5B{ms&_{xA8~>j{;aePDVa02lpc`rw4Z!@${QLKp%# ntdd|P@;?86eEk2dOl$!s6W1};0Z$thHBQOf1LgkoB$o6aJD_A_ diff --git a/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 455be2a69ba7453ef56a9f1e6f1f46b9546bb285..f6d9d87c540a77a8dd0edecd4c7bde1558d351ef 100644 GIT binary patch literal 973 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q2}owBl)eX2Y)RhkE)4%caKYZ?lNlJ8n><|{ zLn`LHy}jT2YM{)qkMBd*y9KCBoZuMfY(I@-id(eI9G2Nr)e2WGyt+t3e}_k4l%rUq z3)eEmsV$1FGg_USd7lZXXoO5jQn;y`YtgKD?WNb9;(O0+*4IC%+4p^~&1v7MreSJ_ z0w4ThIN95DP(z}JcEs|7S$#6utMl*eTYBZI;=PY15rv7n7Ou)$^Yq`!S;Dg~sjfT! zv19qcp4{2n=h@bMN?o>p;i`2T4!jQj%=STh%JgYlubSK2-w%!seEUo6JHx@Azt-Q& z%(i};x38??yW{okJ6br?LS(J1N|vqOyt4FsU+lX4LS``?&q*O#q0xbVA35dEF`guT z#zWlIN_pkM9**=B4tdF|Jv@pt1W)$xsLv4m)5D`1A@r+<$6&_C-#t2>;=Aph@8>j| zk(l;m^_k{`v=ol}38I&JdX#1e9_#7RnBh2o;zd^P2gXLn64*-Ij!B<6aMt5MXC=d| ziCo*b43$N5bkCux#Y@(`X|qOLJYX?r!D!-EpVo%rfl8qpFJTPWMnk%Kd*7q zia2-YoCoV|j&|dqgJ$PW0+r5Cy3XE^TAxyVOl~XRogaTn_450*Zn&*kv&N>zcFxQ9 z75_8U&iiXeoO4qOkCV*UddgVcX6lc%4@wRY21jnkZcU&+Q!$ee8KDBwIXY#wIVrF`9YjX zN0tj_h*lficUus;=inci#xO&+&y_4uuHt7t10@0tj&N-0Qj*AEiDH%GF=txa#LFhz z7{%s9#|_Kjmp z6RY%0nf#a8^Jmn({w0uZa8L>8o`!_LUI*><&8`+qYi)SH>FzG6eerJULF>=m-0Si` vdaqiu@zPh`Nz8v2^1B^O5aFR6@l3qs-n7*JJ!USz{K??y>gTe~DWM4fA5*B* literal 1809 zcmb7Fc{m%`7FR@KYrW1`Dos_HMy;t1QktN)+7hLf1|!9nmYGS=;ZY5mSh`r+YG@*K z(T0qsG`6ZG!U!hHH`J1n7A@6@SfZpU;-xcx%s=ms_rCj``}^+i-gEA`_ndprwE&ct zwuYgGl9H0P_bK;41>XEBAZ0~=!8CbU0cz2{UhYaeU)%lms$vDf^LBSVlS&s0`2?`X z{?5eOcN3`n&ahWq@SXS$|bjxWS%7Qs*-fYFUj}Eu&s&UBkDe?q-4tof~oV=#B1g7{*jx*SB`v!8Z>|T}gFLBxFsdz-B zO|a@#U~x!1e1GAh?VTtNEp|7<%;&S=PHmLeq#$Pf)b!=-o5#ZzhI2CR<)y=` z|5|~YpHHg9IEVSG)AP-S)kM-X+GsbzeO8{Y*Z9(C1Q zY@GP5$cVT;(i_F$tl(S6;ligg7BN#h?sAV8DX9a9tu|v#p5=FX9NEfRilFOaB@r)& z(t=?QrQs*{<;Rs;MbB>6WkkIE`H??byx({?Z-JebN0eWKNzp-}DGlG~?3rvZ>BoUe z#H*eX`CzTc=gQ`XY73pxN>s6_LH(Vu2p>zN6=C~k=1O(Lm}q78hmJeQ6Ghk5O@m?c zzy4cNDAn__w^J9*C;9GGi@EfYF$xw`>4c^yKhowI45ieSC^yTJ-M$*)B;lSE-NtLn5rU6 z$H=&*ve1$zG!=jwkKr41`2fL`^I5iJ!{?lZ12u4<>@!*bkuu6Wm(&Qb(skovvZ&V| zV9&dtdIIW7r>mmP3}|QaS>zd?o`ah61zPH|;|4)T`$#ey$+F+M6-G1+aPdWCN1_~S z(ZWG)Zuqt_4g9!@5TNvj;_cGuVtM;AHY3yr4FkNJzz8m58x(P1k>6nAbZFRiJnI!= zQZw_%$F3Jh9(qs+uS39?8(CXv4l&F&x%<%UUn)_L#_^dN*A+K217bKeN`lLq_db|V zXcuB39j9tVID}=mh4x9ECqgDgt+_i|pqL_IXVSKr6vWT)A(!n#2qcJ9EY8%IDcZwc^Eppc8N`J$}yi`PZ%V2i`L& z64u6_S(^4bYfh;)w<^Uy9q-B5EnpVMRWDiq|N9MYpfPjXQx?4TP>R$|6y|-vR#}u>v^fg(p-wsljQcr z&I-j*7O+(xty?=qe_#(Gzei5B{ms&_{xA8~>j{;aePDVa02lpc`rw4Z!@${QLKp%# ntdd|P@;?86eEk2dOl$!s6W1};0Z$thHBQOf1LgkoB$o6aJD_A_ diff --git a/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png index edd54e2eb2689cbca5a3480edd8988a69be8cf5e..3004b5dfbfe0394ca3a9b20999c06747737891b2 100644 GIT binary patch literal 1200 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d5Vcu_bxCyD(bUW(<@d`VykFciA;03JVA_3JMMwF|mm0*0#dWb6>xE@vH0A6yXcD(q^?A->(gqude$$ zt2Fk05Kx6wubZ}5n9ZK22iJewzq$PQyXf%ym%dttYG^Ikcy)E&tr0n7+BWmrx-KTEnVH4`;SR}Z zQQcWQ8E1U5UH3Bf&dgkRi&@I}@-nprr_XHByFm={^2nW39$p%U;Z!@~gawYrk{7fdP2D|enjJ$64>9ZO-*1Y+}p!W6fdiC$} zwjaLw-!H8_Ap}%$IsN>e4-XeA->=!SW5;HcIKA+vb^6!P^`Eh@~Ctcqj zGrOVc)uE&Halik(WtNx!_xjM!b@@958NZ%edeqx}zwNg-)%yR--hE$}p9yr#&ripN z{pBk^K6x1&D{rL9_|?_kJxrOgbp3hd)e{?bUHL!ps)xf}iBl)CR2Ec9UjtD)rS5cy^}HJ%(VqsW@@m&o)|8a3>lOh;BUdOXC})>(@t-&`Yikm-(WYmM4*-QL zwX}f3|HQ7nyLIa;@7o)|G=1~)^E$iF-q+i&ES)3%;>XJOyX_Xb{X3Xml&*yXQ_uLcja0)FZEGNvz$0ufOg*pw& zH`@~i1y@ePx&tV7dfTE=e7x-i3ZmW6kpO zl`0{=#zaMlK%v?&SQX-b00~IqJZ-QA!LR@IF>mDap4DK4T!1{aa{BHd`ja~3wKyPI zNO4!$OMX5&#ePg(r=1T!f3Uc$*>yd?+PE4Puc{zHijTP*|n>4;@%gs(`gMM%gkj4ZSD4I=cSX1c@fQxU*$_kWb98^`eGL9Y^rf?$%d|b zQy5|>Q-4(IgDTGN3Xk&?-tP}f$|7gB_;tnML<`^;s(Yacbq`Ogr02zjawmBb55y?%>nsSXEqeBw$rp<9We|w7FEE`QyGwG? z`%pETt2VH>-qzxeT|sGhT2~-d3l_I`b-K{eP^);hhdF~3Xi||7Qx$0{${#4RGGKWL zLXNg|U|tY0!zLs+s60_mAe4E$R7k`5)j%O3G9Og2&)(5>D||+V&D;T-BLLu00P&g*8MPm}hY3w`y@jWNCNkh7Lr6Z&E)=@C%<_R1h%EN@^qhM*W}l6x zB}h1^!z&eT*trpuM$LfF=0NtV08`%tTxQK-2I;(sRM%!8Z{<)4r+h(R-HjaL?1?PM zD5C3fCZ0q!=xyFXZjLW2Zq0}fSFJmzxBLn)#ly{!wLQ_X(6YMoE)nx+hHqQk#_Wyq zfv}1iv~f>Jj!0{_zB1|fqb%AN!puS?4#pr5qS5?i!L0@AZsd3p7-G(@*6Ti&_5Fky zR6%jeKBZjUS=oy}j^g{(*K}VdDqLalhBjD?Lq%t>Cx-}$yP-9W-gtr|h3G!GhMmHu zB%=O3?Q>TlAs3~sEe~8XZH7TY-cplu-N;8R0J|`q?uFSF$p=)%mDv0q~m-B}#Ph?LMk3pcmBC4ugml7>Fb=Hkhp8s`{@>(ZSKZE9P{k zAs)wT_~tTIG3(gQ&36Fo@+(-{{T&c7;gRlA1Y%hz69m=P--8dsTH%t%3#FsWo?|tOXASQA zOQF?;DExBO`f_sj@kG&^MtzXf!TjTT;a2+{5OOxs zmL(g91gBhnqmOi?2MiB(S`S3&{7QYn8(qd5gP~4+2wRUxu7h~Ennp5K$aw>VcL7HG z!^)gq&Sg}SZI4~w2^L0>zcR#?+?r-xr@8s08+Odi=xf0d>odTI%hJ@DE@s-T+mJ$# z#@xqr-Gu*WEUzM}p`R*(iVGw)|M(oDA{sY7N5ykbbHkkSvo*!oP~oJeyEGlJF67zw zG%Zf`UJ}u${log^uR2_2)kaUWJEYLYH)mzJ9*ZWlQ({$`>Z4-RtNpc*+%Yfr*WY=0?*eXDaLjNtnx!AHC;CxEs_7lbq|yAaDwOA>{IG=zVNUUMw2BLc=wXB!2j5@XTVydrPZ*^Seh zdq()D-}hlxW;BgQ_-$Bw7?EZJMlYvY=(?)k$ivDF>*cK96EbM(bTdvlE$%_Uv+DJQ z@^;&ZwU~!LsqL5bGbhA~>z-WQysDaYFG;F7A70fyvB}XDb0EE24~WLzRs?s34jDuj%-& cpV<(feB~KiITJAk_FsI~Cr_dNvbYfQHxo3P)&Kwi diff --git a/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index edd54e2eb2689cbca5a3480edd8988a69be8cf5e..3004b5dfbfe0394ca3a9b20999c06747737891b2 100644 GIT binary patch literal 1200 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d5Vcu_bxCyD(bUW(<@d`VykFciA;03JVA_3JMMwF|mm0*0#dWb6>xE@vH0A6yXcD(q^?A->(gqude$$ zt2Fk05Kx6wubZ}5n9ZK22iJewzq$PQyXf%ym%dttYG^Ikcy)E&tr0n7+BWmrx-KTEnVH4`;SR}Z zQQcWQ8E1U5UH3Bf&dgkRi&@I}@-nprr_XHByFm={^2nW39$p%U;Z!@~gawYrk{7fdP2D|enjJ$64>9ZO-*1Y+}p!W6fdiC$} zwjaLw-!H8_Ap}%$IsN>e4-XeA->=!SW5;HcIKA+vb^6!P^`Eh@~Ctcqj zGrOVc)uE&Halik(WtNx!_xjM!b@@958NZ%edeqx}zwNg-)%yR--hE$}p9yr#&ripN z{pBk^K6x1&D{rL9_|?_kJxrOgbp3hd)e{?bUHL!ps)xf}iBl)CR2Ec9UjtD)rS5cy^}HJ%(VqsW@@m&o)|8a3>lOh;BUdOXC})>(@t-&`Yikm-(WYmM4*-QL zwX}f3|HQ7nyLIa;@7o)|G=1~)^E$iF-q+i&ES)3%;>XJOyX_Xb{X3Xml&*yXQ_uLcja0)FZEGNvz$0ufOg*pw& zH`@~i1y@ePx&tV7dfTE=e7x-i3ZmW6kpO zl`0{=#zaMlK%v?&SQX-b00~IqJZ-QA!LR@IF>mDap4DK4T!1{aa{BHd`ja~3wKyPI zNO4!$OMX5&#ePg(r=1T!f3Uc$*>yd?+PE4Puc{zHijTP*|n>4;@%gs(`gMM%gkj4ZSD4I=cSX1c@fQxU*$_kWb98^`eGL9Y^rf?$%d|b zQy5|>Q-4(IgDTGN3Xk&?-tP}f$|7gB_;tnML<`^;s(Yacbq`Ogr02zjawmBb55y?%>nsSXEqeBw$rp<9We|w7FEE`QyGwG? z`%pETt2VH>-qzxeT|sGhT2~-d3l_I`b-K{eP^);hhdF~3Xi||7Qx$0{${#4RGGKWL zLXNg|U|tY0!zLs+s60_mAe4E$R7k`5)j%O3G9Og2&)(5>D||+V&D;T-BLLu00P&g*8MPm}hY3w`y@jWNCNkh7Lr6Z&E)=@C%<_R1h%EN@^qhM*W}l6x zB}h1^!z&eT*trpuM$LfF=0NtV08`%tTxQK-2I;(sRM%!8Z{<)4r+h(R-HjaL?1?PM zD5C3fCZ0q!=xyFXZjLW2Zq0}fSFJmzxBLn)#ly{!wLQ_X(6YMoE)nx+hHqQk#_Wyq zfv}1iv~f>Jj!0{_zB1|fqb%AN!puS?4#pr5qS5?i!L0@AZsd3p7-G(@*6Ti&_5Fky zR6%jeKBZjUS=oy}j^g{(*K}VdDqLalhBjD?Lq%t>Cx-}$yP-9W-gtr|h3G!GhMmHu zB%=O3?Q>TlAs3~sEe~8XZH7TY-cplu-N;8R0J|`q?uFSF$p=)%mDv0q~m-B}#Ph?LMk3pcmBC4ugml7>Fb=Hkhp8s`{@>(ZSKZE9P{k zAs)wT_~tTIG3(gQ&36Fo@+(-{{T&c7;gRlA1Y%hz69m=P--8dsTH%t%3#FsWo?|tOXASQA zOQF?;DExBO`f_sj@kG&^MtzXf!TjTT;a2+{5OOxs zmL(g91gBhnqmOi?2MiB(S`S3&{7QYn8(qd5gP~4+2wRUxu7h~Ennp5K$aw>VcL7HG z!^)gq&Sg}SZI4~w2^L0>zcR#?+?r-xr@8s08+Odi=xf0d>odTI%hJ@DE@s-T+mJ$# z#@xqr-Gu*WEUzM}p`R*(iVGw)|M(oDA{sY7N5ykbbHkkSvo*!oP~oJeyEGlJF67zw zG%Zf`UJ}u${log^uR2_2)kaUWJEYLYH)mzJ9*ZWlQ({$`>Z4-RtNpc*+%Yfr*WY=0?*eXDaLjNtnx!AHC;CxEs_7lbq|yAaDwOA>{IG=zVNUUMw2BLc=wXB!2j5@XTVydrPZ*^Seh zdq()D-}hlxW;BgQ_-$Bw7?EZJMlYvY=(?)k$ivDF>*cK96EbM(bTdvlE$%_Uv+DJQ z@^;&ZwU~!LsqL5bGbhA~>z-WQysDaYFG;F7A70fyvB}XDb0EE24~WLzRs?s34jDuj%-& cpV<(feB~KiITJAk_FsI~Cr_dNvbYfQHxo3P)&Kwi From 9aafc003cb36e4f6e8e502e4cf0e7856423f6a28 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:19:12 -0700 Subject: [PATCH 15/87] vc=31: smoother swipe-to-minimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote the drag-to-dismiss state machine. Old version launched a coroutine per pointer event to call Animatable.snapTo (which is suspend) — multiple launches racing per frame caused the stutter. Two-state pattern now: liveDrag (mutableFloatStateOf) — updated synchronously inside rememberDraggableState's callback. One state write per pointer event, no coroutine spawn during the drag itself. releaseAnim (Animatable) — driven by a single coroutine in Modifier.draggable's onDragStopped. Either spring-back to 0 or slide off-screen + onMinimize. graphicsLayer reads liveDrag when actively dragging, releaseAnim otherwise — a single Boolean gate. Bonus: dismiss now SLIDES the page off-screen before popping nav, instead of cutting. tween(220ms, FastOutLinearInEasing). Spring-back on a short drag uses MediumBouncy/MediumLow for a real spring feel instead of a hard snap. Fling-velocity threshold (600dp/s) also counts — flick-down past 600dp/s dismisses even if the drag distance was short. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../straw/feature/detail/VideoDetailScreen.kt | 92 +++++++++++++------ 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 021a8e0ff..ce37f0b7c 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 30 -const val STRAW_VERSION_NAME = "0.1.0-AP" +const val STRAW_VERSION_CODE = 31 +const val STRAW_VERSION_NAME = "0.1.0-AQ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 502314b25..59f48f148 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -13,11 +13,15 @@ import android.util.Rational import android.widget.Toast import androidx.annotation.OptIn import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -63,14 +67,13 @@ 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.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -98,7 +101,6 @@ import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml -import kotlinx.coroutines.launch @OptIn(ExperimentalLayoutApi::class, UnstableApi::class) @Composable @@ -132,39 +134,73 @@ fun VideoDetailScreen( } // Swipe-down to minimize. The drag handle is the inline player surface - // (the 16:9 box at the top); we translate the WHOLE page with it so the + // at the top of the page; we translate the WHOLE page with it so the // motion reads as "the video is being tucked away" rather than "this - // one widget slid." The graphicsLayer + alpha/scale fade keeps it - // smooth — no per-pixel coroutine churn from offset { }. + // one widget slid." + // + // Two-state pattern so the drag stays smooth at 120fps: + // liveDrag — mutableFloatStateOf updated SYNCHRONOUSLY in + // rememberDraggableState's callback. One state write + // per pointer event, no coroutine spawn. + // releaseAnim — Animatable driven by a single coroutine that + // runs only when the finger leaves (spring back + // if short, slide off-screen + onMinimize if past + // threshold or flung). + // graphicsLayer reads whichever is active via the `dragging` flag. + // The old single-Animatable / scope.launch-per-pixel pattern + // raced coroutines for every drag delta and stuttered on fast + // gestures; this doesn't. val density = LocalDensity.current + val configuration = LocalConfiguration.current val dismissThresholdPx = with(density) { 140.dp.toPx() } - val dragY = remember { Animatable(0f) } - val scope = rememberCoroutineScope() - val playerDragModifier = Modifier.pointerInput(Unit) { - detectVerticalDragGestures( - onDragEnd = { - if (dragY.value > dismissThresholdPx) { - onMinimize() - } else { - scope.launch { dragY.animateTo(0f, spring()) } - } - }, - onDragCancel = { - scope.launch { dragY.animateTo(0f, spring()) } - }, - onVerticalDrag = { _, dy -> - scope.launch { - dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) - } - }, - ) + val flingVelocityThreshold = with(density) { 600.dp.toPx() } + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } + var liveDrag by remember { mutableStateOf(0f) } + var dragging by remember { mutableStateOf(false) } + val releaseAnim = remember { Animatable(0f) } + val draggableState = rememberDraggableState { delta -> + liveDrag = (liveDrag + delta).coerceAtLeast(0f) } + val playerDragModifier = Modifier.draggable( + orientation = Orientation.Vertical, + state = draggableState, + onDragStarted = { + releaseAnim.stop() + liveDrag = releaseAnim.value + dragging = true + }, + onDragStopped = { velocity -> + val shouldDismiss = + liveDrag > dismissThresholdPx || velocity > flingVelocityThreshold + releaseAnim.snapTo(liveDrag) + dragging = false + if (shouldDismiss) { + // Slide the rest of the way off-screen, then pop. The + // pop happens AFTER the animation so the user sees the + // page leave under their finger instead of a hard cut. + releaseAnim.animateTo( + screenHeightPx, + tween(durationMillis = 220, easing = FastOutLinearInEasing), + ) + onMinimize() + } else { + releaseAnim.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + } + liveDrag = 0f + }, + ) Column( modifier = Modifier .fillMaxSize() .graphicsLayer { - val y = dragY.value + val y = if (dragging) liveDrag else releaseAnim.value translationY = y val p = (y / dismissThresholdPx).coerceIn(0f, 1f) alpha = 1f - p * 0.4f From 544035b30c47c311edbbf548e0022eaa6dc4c61b Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:34:02 -0700 Subject: [PATCH 16/87] =?UTF-8?q?vc=3D32:=20subs=20feed=20=E2=80=94=20date?= =?UTF-8?q?s,=20watched=20filter,=20infinite=20scroll,=20avatar=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription feed is now actually a feed instead of a teaser. Rust (strawcore wrapper) Added upload_date_relative and uploader_avatar to SearchItem so Kotlin can see both. strawcore-core already extracts upload_date relative ("2 days ago") on every StreamInfoItem and uploader_avatars on most — we were just throwing them away in from_core. Fixed. StreamItem uploadDateRelative + uploaderAvatar fields added. Every construction site (search/channel/detail/feed) plumbs them through. SubscriptionFeedViewModel Per-channel cap 5 → 30. With 30 subs that's up to 900 items in memory; ConcurrentHashMap entries are small enough. Sort by parsed relative recency (RECENCY_RE on the "N ago" string, signed seconds-ago, tied items break by viewCount). Opportunistic avatar backfill: every successful channelInfo fetch updates the stored ChannelRef.avatar via Subscriptions.updateAvatar when strawcore returns a non-null avatar — fixes the "I just subbed to a channel and the chip has no icon" case where the channel header parser missed the avatar at subscribe time but the feed-fetch layout returns one. SubsPane (StrawHome) Hide-watched FilterChip (session-sticky). Cross-references History.watches by 11-char YT video ID; filters out anything you've already watched. "All caught up — nothing unwatched" empty state. Infinite scroll: PAGE_SIZE = 20. derivedStateOf-gated snapshotFlow watches the LazyListState's lastVisibleItem index; when within 5 items of the bottom, bumps visibleCount by 20. "Loading more..." spinner at the bottom while there's more to show. Visible-count resets to PAGE_SIZE when the underlying list shrinks (refresh dropped items, filter just engaged). FeedRow now shows: uploader · views · "3 days ago". SubChip Lettered fallback when ch.avatar is null. PrimaryContainer-tinted circle with the first letter — no more broken-image placeholder while the feed-fetch backfills the real avatar. SubscriptionsStore updateAvatar(url, avatar) for the backfill path. Atomic via updateAndGet, persists to SharedPreferences. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/search.rs | 11 ++ .../main/kotlin/com/sulkta/straw/StrawHome.kt | 138 +++++++++++++++++- .../sulkta/straw/data/SubscriptionsStore.kt | 13 ++ .../straw/feature/channel/ChannelViewModel.kt | 2 + .../feature/detail/VideoDetailViewModel.kt | 4 + .../feature/feed/SubscriptionFeedViewModel.kt | 93 +++++++++--- .../straw/feature/search/SearchViewModel.kt | 5 + 8 files changed, 244 insertions(+), 26 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index ce37f0b7c..e6a729b1a 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 31 -const val STRAW_VERSION_NAME = "0.1.0-AQ" +const val STRAW_VERSION_CODE = 32 +const val STRAW_VERSION_NAME = "0.1.0-AR" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index b4f96395e..c7f573459 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -15,11 +15,16 @@ pub struct SearchItem { pub title: String, pub uploader: String, pub uploader_url: Option, + pub uploader_avatar: Option, pub thumbnail: Option, /// Duration in seconds. 0 = live/unknown. pub duration_seconds: i64, /// Reported view count. 0 = unknown. pub view_count: i64, + /// Relative upload date as YT renders it ("2 days ago", "3 weeks + /// ago"). Empty if not extracted. Strawcore-core already populates + /// this on StreamInfoItem; we just pass it through. + pub upload_date_relative: String, } pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { @@ -32,11 +37,16 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { .thumbnails .last() .map(|i| i.url().to_string()); + let uploader_avatar = item + .uploader_avatars + .last() + .map(|i| i.url().to_string()); SearchItem { url: item.url, title: item.name, uploader: item.uploader_name, uploader_url, + uploader_avatar, thumbnail, duration_seconds: item.duration_seconds, view_count: if item.view_count < 0 { @@ -44,6 +54,7 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { } else { item.view_count }, + upload_date_relative: item.upload_date_relative, } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 85c4b9a6e..dd23aeae4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -37,6 +38,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,11 +57,14 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -262,8 +268,35 @@ private fun SubsPane( ) { val subs by Subscriptions.get().subs.collectAsState() val feed by feedVm.ui.collectAsState() + val watches by History.get().watches.collectAsState() LaunchedEffect(subs) { feedVm.refreshIfStale() } + // Filter + pagination state. hideWatched is sticky for the session + // (no SharedPreferences yet — easy to add if Cobb wants persistence). + // visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time + // the scroll passes ~5 items from the bottom of what's currently + // visible. + var hideWatched by remember { mutableStateOf(false) } + var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) } + + // O(1) lookup for the watched-filter; rebuild only when watches + // change. Just the video IDs because URLs vary by tracking params. + val watchedIds = remember(watches) { watches.map { it.videoId }.toSet() } + + val filteredItems = remember(feed.items, hideWatched, watchedIds) { + if (!hideWatched) feed.items + else feed.items.filterNot { extractVideoId(it.url) in watchedIds } + } + // Reset pagination when the underlying list changes so the user + // doesn't end up looking at "no more items" after a refresh. + LaunchedEffect(filteredItems) { + if (visibleCount > filteredItems.size.coerceAtLeast(PAGE_SIZE)) { + visibleCount = PAGE_SIZE + } + } + val displayed = filteredItems.take(visibleCount) + val hasMore = filteredItems.size > visibleCount + Column { if (subs.isEmpty()) { Text( @@ -287,6 +320,13 @@ private fun SubsPane( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) + FilterChip( + selected = hideWatched, + onClick = { hideWatched = !hideWatched }, + label = { Text("Hide watched") }, + colors = FilterChipDefaults.filterChipColors(), + ) + Spacer(modifier = Modifier.width(8.dp)) TextButton(onClick = { feedVm.refresh() }) { Text(if (feed.loading) "..." else "Refresh") } @@ -329,18 +369,76 @@ private fun SubsPane( color = MaterialTheme.colorScheme.error, ) } + feed.items.isNotEmpty() && filteredItems.isEmpty() -> { + Text( + "All caught up — nothing unwatched.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else -> { - LazyColumn { - items(feed.items) { item -> + val listState = rememberLazyListState() + // Bump visibleCount when the user scrolls within 5 items + // of the current bottom. snapshotFlow + derivedStateOf + // keeps this off the per-frame recompose path. + val nearBottom by remember { + derivedStateOf { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible >= info.totalItemsCount - 5 + } + } + LaunchedEffect(displayed.size, hasMore) { + snapshotFlow { nearBottom }.collect { atEnd -> + if (atEnd && hasMore) { + visibleCount = (visibleCount + PAGE_SIZE) + .coerceAtMost(filteredItems.size) + } + } + } + LazyColumn(state = listState) { + items(displayed) { item -> FeedRow(item) { onOpenVideo(item.url, item.title) } HorizontalDivider() } + if (hasMore) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Loading more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } } } } } +private const val PAGE_SIZE = 20 + +/** + * Extract the YouTube video ID from a watch URL so we can cross-check + * against History.watches (which stores videoId, not full URL). Handles + * the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X... + * Returns empty string when nothing matches — callers compare against + * watchedIds, so an empty string just won't filter anything out. + */ +private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$") +private fun extractVideoId(url: String): String = + VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty() + @Composable private fun FeedRow(item: StreamItem, onClick: () -> Unit) { Row( @@ -374,6 +472,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) { append(" · ") append(formatViews(item.viewCount)) } + if (item.uploadDateRelative.isNotBlank()) { + append(" · ") + append(item.uploadDateRelative) + } }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -437,11 +539,33 @@ private fun SubChip( .clickable { onOpenChannel(ch.url, ch.name) }, horizontalAlignment = Alignment.CenterHorizontally, ) { - AsyncImage( - model = ch.avatar, - contentDescription = null, - modifier = Modifier.size(56.dp).clip(CircleShape), - ) + if (ch.avatar.isNullOrBlank()) { + // Lettered fallback — strawcore can return a null avatar + // when the channel header layout doesn't include one (more + // common on smaller channels). Feed-fetch backfills this + // asynchronously via Subscriptions.updateAvatar, but until + // it arrives we still want SOMETHING visible. + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = ch.name.firstOrNull()?.uppercase().orEmpty(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + AsyncImage( + model = ch.avatar, + contentDescription = null, + modifier = Modifier.size(56.dp).clip(CircleShape), + ) + } Spacer(modifier = Modifier.height(4.dp)) Text( text = ch.name, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index b90d9b406..303ac4678 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -51,6 +51,19 @@ class SubscriptionsStore(context: Context) { persist(next) } + /** + * Update the cached avatar for an already-subscribed channel. Used + * by the subs feed fetch when it pulls a fresh ChannelInfo and the + * stored ChannelRef has a null avatar (channel header parser missed + * it at subscribe time). No-op for non-subscribed URLs. + */ + fun updateAvatar(channelUrl: String, avatar: String) { + val next = _subs.updateAndGet { cur -> + cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it } + } + persist(next) + } + fun clear() { // Same atomic-update path as toggle — protects against a concurrent // toggle racing the clear and persisting [new-item] after the diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 90dd8ea05..fe3a38adc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -42,9 +42,11 @@ class ChannelViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader, uploaderUrl = v.uploaderUrl, + uploaderAvatar = v.uploaderAvatar ?: ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, + uploadDateRelative = v.uploadDateRelative, ) } _ui.value = ChannelUiState( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 476615c25..43eaceebb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -129,9 +129,11 @@ class VideoDetailViewModel : ViewModel() { title = r.title.ifBlank { "(no title)" }, uploader = r.uploader, uploaderUrl = r.uploaderUrl, + uploaderAvatar = r.uploaderAvatar, thumbnail = r.thumbnail, durationSeconds = r.durationSeconds, viewCount = r.viewCount, + uploadDateRelative = r.uploadDateRelative, ) } @@ -151,9 +153,11 @@ class VideoDetailViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { uploader }, uploaderUrl = v.uploaderUrl ?: uploaderUrl, + uploaderAvatar = v.uploaderAvatar ?: ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, + uploadDateRelative = v.uploadDateRelative, ) } }.getOrDefault(emptyList()) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index f69be037f..b6fc0c49b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -3,18 +3,15 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Aggregate latest videos across all subscribed channels into a single - * feed. Fans out per-channel channelInfo() fetches in parallel, caches - * each channel's videos independently, merges by view-count desc, caps - * at 200 items. + * feed. Per-channel fan-out with independent TTL caches. Bigger per + * channel limit so the feed actually feels "show me everything new", + * sorted by parsed relative upload date so the merged list reads + * newest-first across channels. * - * Each per-channel cache entry has its own TTL so adding one new - * subscription doesn't invalidate the other 49 — only the new one - * actually goes to the network on the next refresh. - * - * Concurrency hardening: cancel any in-flight refresh when a new one - * starts, cap parallelism with a Semaphore so 100+ subs don't slam YT, - * time-bound each per-channel fetch so one hung channel can't stall the - * whole batch. + * Also opportunistically refreshes a channel's avatar in + * SubscriptionsStore — strawcore can occasionally return null on first + * subscribe (the channel header layout varies); a subsequent feed fetch + * will fill it in automatically. */ package com.sulkta.straw.feature.feed @@ -63,6 +60,13 @@ class SubscriptionFeedViewModel : ViewModel() { /** Cap parallel network fetches even with 100+ subs. */ private val parallelism = 8 + /** + * Videos pulled per channel. Bumped from 5 → 30 so "show me + * everything new from my subs" actually has body to it; cheap to + * keep in memory at this size (30 subs * 30 videos = 900 max). + */ + private val perChannelMax = 30 + /** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */ private var inFlight: Job? = null @@ -116,19 +120,30 @@ class SubscriptionFeedViewModel : ViewModel() { } private suspend fun fetchChannelInto(ch: ChannelRef) { - val perChannelMax = 5 - val fetched = withTimeoutOrNull(perChannelTimeoutMs) { + val outcome = withTimeoutOrNull(perChannelTimeoutMs) { runCatching { val info = uniffi.strawcore.channelInfo(ch.url) + // Opportunistic avatar refresh: if our stored ChannelRef + // didn't capture an avatar at subscribe-time (channel + // header parser missed it, or user subscribed before the + // page loaded), backfill from the channel info now. + val freshAvatar = info.avatar + if (!freshAvatar.isNullOrBlank() && freshAvatar != ch.avatar) { + runCatching { + Subscriptions.get().updateAvatar(ch.url, freshAvatar) + } + } info.videos.take(perChannelMax).map { v -> StreamItem( url = v.url, title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { ch.name }, uploaderUrl = v.uploaderUrl ?: ch.url, + uploaderAvatar = v.uploaderAvatar ?: freshAvatar ?: ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, + uploadDateRelative = v.uploadDateRelative, ) } }.onFailure { @@ -141,8 +156,8 @@ class SubscriptionFeedViewModel : ViewModel() { // Only update the cache on a successful fetch. A timeout/error // leaves any prior cache entry intact, so a glitchy channel // doesn't blank your feed for that channel. - if (fetched.isNotEmpty()) { - channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), fetched) + if (outcome.isNotEmpty()) { + channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), outcome) } } @@ -152,7 +167,51 @@ class SubscriptionFeedViewModel : ViewModel() { // fall out of the feed immediately. channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) } return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() } - .sortedByDescending { it.viewCount } - .take(200) + // Newest-first across channels. Falls back to viewCount when + // we couldn't parse the relative date (older items + live + // streams come back without one). + .sortedWith( + compareByDescending { it.recencyScore() } + .thenByDescending { it.viewCount }, + ) + // Generous cap. Anything past this is almost certainly noise + // for a feed view; pagination in the UI further slices this. + .take(500) } } + +/** + * Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style + * strings into approximate seconds-ago. Higher = more recent (so default + * sort is descending). Returns Long.MIN_VALUE when we can't parse — those + * sink to the bottom of the feed. + * + * Strawcore-core (and YT before it) emits these in English-only locale + * for the InnerTube web client; if we ever localize the extractor this + * regex needs to grow. + */ +private val RECENCY_RE = Regex( + """(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago""", + RegexOption.IGNORE_CASE, +) + +private fun StreamItem.recencyScore(): Long { + val s = uploadDateRelative + if (s.isBlank()) return Long.MIN_VALUE + val m = RECENCY_RE.find(s) ?: return Long.MIN_VALUE + val n = m.groupValues[1].toLongOrNull() ?: return Long.MIN_VALUE + val unitSecs: Long = when (m.groupValues[2].lowercase()) { + "second" -> 1 + "minute" -> 60 + "hour" -> 3600 + "day" -> 86_400 + "week" -> 604_800 + "month" -> 2_592_000 // approx 30 days + "year" -> 31_536_000 + else -> return Long.MIN_VALUE + } + // Sign flip: smaller "seconds ago" → larger score (more recent). + // Cap at a sane horizon so a "1 second ago" doesn't overwhelm the + // viewCount tiebreaker on items that are functionally tied. + return -(n * unitSecs) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index e48aecd83..f44ce5f0f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -25,9 +25,12 @@ data class StreamItem( val title: String, val uploader: String, val uploaderUrl: String?, + val uploaderAvatar: String? = null, val thumbnail: String?, val durationSeconds: Long, val viewCount: Long, + /** "2 days ago" / "3 weeks ago" / empty if not extracted. */ + val uploadDateRelative: String = "", ) class SearchViewModel : ViewModel() { @@ -53,9 +56,11 @@ class SearchViewModel : ViewModel() { title = r.title.ifBlank { "(no title)" }, uploader = r.uploader, uploaderUrl = r.uploaderUrl, + uploaderAvatar = r.uploaderAvatar, thumbnail = r.thumbnail, durationSeconds = r.durationSeconds, viewCount = r.viewCount, + uploadDateRelative = r.uploadDateRelative, ) } _ui.value = _ui.value.copy(loading = false, results = items) From 2afdcf3d5cd709d73becd21aecb9810dd2cd0495 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:38:50 -0700 Subject: [PATCH 17/87] =?UTF-8?q?vc=3D32=20fix:=20drop=20SearchItem.upload?= =?UTF-8?q?er=5Favatar=20=E2=80=94=20not=20on=20StreamInfoItem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I confused StreamInfo (the big single-video struct, has uploader_avatars: ImageSet) with StreamInfoItem (the card struct used in search results / channel video lists / related streams — no uploader_avatars field). cargoBuildHost caught it: E0609 no field `uploader_avatars`. Drop the field from SearchItem (and from the Kotlin construction sites). For the subs feed and "more from this channel" we already use the channel-level avatar from ChannelInfo.avatar, which is the right granularity anyway (every video from one channel shares one avatar). Per-card uploader avatars on search/related stay null until strawcore-core extracts them on StreamInfoItem too. --- rust/strawcore/src/search.rs | 6 ------ .../com/sulkta/straw/feature/channel/ChannelViewModel.kt | 2 +- .../com/sulkta/straw/feature/detail/VideoDetailViewModel.kt | 3 +-- .../sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt | 2 +- .../com/sulkta/straw/feature/search/SearchViewModel.kt | 1 - 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index c7f573459..b85b7ce7f 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -15,7 +15,6 @@ pub struct SearchItem { pub title: String, pub uploader: String, pub uploader_url: Option, - pub uploader_avatar: Option, pub thumbnail: Option, /// Duration in seconds. 0 = live/unknown. pub duration_seconds: i64, @@ -37,16 +36,11 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { .thumbnails .last() .map(|i| i.url().to_string()); - let uploader_avatar = item - .uploader_avatars - .last() - .map(|i| i.url().to_string()); SearchItem { url: item.url, title: item.name, uploader: item.uploader_name, uploader_url, - uploader_avatar, thumbnail, duration_seconds: item.duration_seconds, view_count: if item.view_count < 0 { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index fe3a38adc..027d0bfa9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -42,7 +42,7 @@ class ChannelViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader, uploaderUrl = v.uploaderUrl, - uploaderAvatar = v.uploaderAvatar ?: ch.avatar, + uploaderAvatar = ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 43eaceebb..e269b3f6c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -129,7 +129,6 @@ class VideoDetailViewModel : ViewModel() { title = r.title.ifBlank { "(no title)" }, uploader = r.uploader, uploaderUrl = r.uploaderUrl, - uploaderAvatar = r.uploaderAvatar, thumbnail = r.thumbnail, durationSeconds = r.durationSeconds, viewCount = r.viewCount, @@ -153,7 +152,7 @@ class VideoDetailViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { uploader }, uploaderUrl = v.uploaderUrl ?: uploaderUrl, - uploaderAvatar = v.uploaderAvatar ?: ch.avatar, + uploaderAvatar = ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index b6fc0c49b..bb6ce845d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -139,7 +139,7 @@ class SubscriptionFeedViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { ch.name }, uploaderUrl = v.uploaderUrl ?: ch.url, - uploaderAvatar = v.uploaderAvatar ?: freshAvatar ?: ch.avatar, + uploaderAvatar = freshAvatar ?: ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index f44ce5f0f..40ff14603 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -56,7 +56,6 @@ class SearchViewModel : ViewModel() { title = r.title.ifBlank { "(no title)" }, uploader = r.uploader, uploaderUrl = r.uploaderUrl, - uploaderAvatar = r.uploaderAvatar, thumbnail = r.thumbnail, durationSeconds = r.durationSeconds, viewCount = r.viewCount, From 69560889ae2b4399fb75607ceef7d013af2dfbb5 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:50:13 -0700 Subject: [PATCH 18/87] vc=33: persistent feed cache + strawcore avatar fix (via strawcore-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-avatar issue and the sluggish-load issue both addressed. strawcore-core (Sulkta-Coop/strawcore @ 7c71511) — separate repo, already committed + pushed: Channel parser was only pulling avatar from the legacy c4TabbedHeaderRenderer branch. The newer pageHeaderRenderer (most channels with a 2024+ refreshed header — including WTYP) was returning empty avatars. Added a deep-nested ViewModel walk for pageHeaderRenderer, plus a metadata.channelMetadataRenderer .avatar.thumbnails[] backfill. WTYP and other re-headered channels should now show their icon. Persistent feed cache (data/FeedCacheStore.kt): New SharedPreferences-backed JSON store. ~225 KB at the upper bound (30 subs * 30 items * ~250 bytes), well within SP's comfort zone. Survives process death. SubscriptionFeedViewModel: Hydrates from FeedCacheStore on init. The Subs tab now paints cached items in one frame on cold start, then refreshes stale channels in the background. Persists the cache after each successful refresh via Dispatchers.IO. Per-channel TTL bumped 10min → 30min (disk cache amortizes the cost; stale-from-disk + background-refresh feels like instant). Per-channel timeout 15s → 10s (slow channel rides cached value instead of stalling the batch). Parallelism 8 → 12 (less network-bound now that UI doesn't wait for first byte). StreamItem now @Serializable so the cache can encode it. FeedCache.init wired into StrawApp.onCreate alongside the other SharedPreferences-backed stores. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../feature/feed/SubscriptionFeedViewModel.kt | 62 ++++++++++++++++--- .../straw/feature/search/SearchViewModel.kt | 1 + 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e6a729b1a..9319ebcd7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 32 -const val STRAW_VERSION_NAME = "0.1.0-AR" +const val STRAW_VERSION_CODE = 33 +const val STRAW_VERSION_NAME = "0.1.0-AS" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index ca36407c9..95be0a44e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -6,6 +6,7 @@ package com.sulkta.straw import android.app.Application +import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Settings @@ -23,5 +24,6 @@ class StrawApp : Application() { Settings.init(this) Subscriptions.init(this) Playlists.init(this) + FeedCache.init(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index bb6ce845d..03eb764f7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -19,9 +19,12 @@ package com.sulkta.straw.feature.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.data.FeedCacheEntry import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -33,6 +36,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap @@ -47,18 +51,52 @@ class SubscriptionFeedViewModel : ViewModel() { private val _ui = MutableStateFlow(SubscriptionFeedUiState()) val ui: StateFlow = _ui.asStateFlow() - /** Per-channel cache: each entry refreshes independently. */ - private data class ChannelCacheEntry(val fetchedAt: Long, val items: List) - private val channelCache = ConcurrentHashMap() + /** + * Per-channel cache: each entry refreshes independently. Hydrated + * from disk on init via FeedCacheStore so cold app starts can show + * the last successful fetch instantly. ConcurrentHashMap because + * fetchChannelInto writes concurrently from the per-channel + * coroutines; mergeFromCache and refreshIfStale read. + */ + private val channelCache = ConcurrentHashMap() /** Per-channel TTL — Refresh just re-fetches stale entries. */ - private val perChannelTtlMs = 10L * 60 * 1000 + private val perChannelTtlMs = 30L * 60 * 1000 - /** Per-channel fetch timeout — slowest channel can't stall the batch. */ - private val perChannelTimeoutMs = 15_000L + init { + // Hydrate from disk and immediately render the cached items so + // the Subs tab paints in one frame instead of after the network + // round-trip. The refresh that follows replaces stale entries + // in-place — items animate to their new positions via LazyColumn + // key stability (URLs are stable across fetches). + val saved = FeedCache.get().load() + if (saved.isNotEmpty()) { + channelCache.putAll(saved) + val channels = Subscriptions.get().subs.value + if (channels.isNotEmpty()) { + _ui.value = _ui.value.copy( + items = mergeFromCache(channels), + lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L, + ) + } + } + } - /** Cap parallel network fetches even with 100+ subs. */ - private val parallelism = 8 + /** + * Per-channel fetch timeout. 10s instead of 15s — a channel that + * hasn't responded in 10s is likely a transient network hiccup or a + * dead channel handle; better to drop it from the batch and ride + * the disk-cache stale value than block the whole feed. + */ + private val perChannelTimeoutMs = 10_000L + + /** + * Parallel network fetches. 12 instead of 8 — with the disk cache + * now buffering UI from network latency, the dominant cost is + * end-to-end batch completion, which is bottle-necked by the + * slowest network round-trip in each parallel group. + */ + private val parallelism = 12 /** * Videos pulled per channel. Bumped from 5 → 30 so "show me @@ -108,6 +146,12 @@ class SubscriptionFeedViewModel : ViewModel() { lastFetchedAt = System.currentTimeMillis(), ) } + // Persist what we just freshened. Off the main thread — + // JSON encode on 30 subs * 30 items is small but not + // free, and SharedPreferences.apply is async anyway. + withContext(Dispatchers.IO) { + runCatching { FeedCache.get().save(channelCache.toMap()) } + } } catch (t: Throwable) { _ui.update { it.copy( @@ -157,7 +201,7 @@ class SubscriptionFeedViewModel : ViewModel() { // leaves any prior cache entry intact, so a glitchy channel // doesn't blank your feed for that channel. if (outcome.isNotEmpty()) { - channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), outcome) + channelCache[ch.url] = FeedCacheEntry(System.currentTimeMillis(), outcome) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 40ff14603..0b3cc78b3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -20,6 +20,7 @@ data class SearchUiState( val error: String? = null, ) +@kotlinx.serialization.Serializable data class StreamItem( val url: String, val title: String, From c74b06436f6795d2fee76952908dbd1c9650f185 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:54:09 -0700 Subject: [PATCH 19/87] vc=33 fix: add FeedCacheStore.kt to git git commit -am only stages tracked files. The new file existed locally but wasn't in the previous commit; build failed with Unresolved reference 'FeedCache' on every site that used it. --- .../com/sulkta/straw/data/FeedCacheStore.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt new file mode 100644 index 000000000..70d9a2c1b --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Persistent per-channel cache for the subscription feed. Survives + * process death, so opening Subs after a cold start shows the last + * successful fetch immediately instead of waiting 5+ seconds for 30 + * channel browses to resolve. + * + * Storage: SharedPreferences with a single JSON blob. Total payload is + * small (30 subs * 30 items * ~250 bytes = ~225 KB), well within SP's + * comfortable size and well below the multi-MB threshold where you'd + * want to graduate to Room or a file. + * + * Concurrency: writes from the feed VM are debounced via the single + * `persist` call inside fetchChannelInto's success path. Reads happen + * on VM init and are synchronous. + */ + +package com.sulkta.straw.data + +import android.content.Context +import android.content.SharedPreferences +import com.sulkta.straw.feature.search.StreamItem +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class FeedCacheEntry( + val fetchedAt: Long, + val items: List, +) + +private const val PREFS = "straw_feed_cache" +private const val KEY = "cache_v1" + +class FeedCacheStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** Snapshot of the disk cache. Returns empty map if nothing saved. */ + fun load(): Map = runCatching { + val s = sp.getString(KEY, null) ?: return emptyMap() + json.decodeFromString>(s) + }.getOrDefault(emptyMap()) + + /** Atomic write. Caller is responsible for diffing if needed. */ + fun save(map: Map) { + val s = json.encodeToString(map) + sp.edit().putString(KEY, s).apply() + } + + fun clear() { + sp.edit().remove(KEY).apply() + } +} + +object FeedCache { + @Volatile private var instance: FeedCacheStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = FeedCacheStore(context.applicationContext) + } + } + } + + fun get(): FeedCacheStore = instance + ?: error("FeedCacheStore not initialized — call FeedCache.init(context)") +} From a776fbf2e419f05f3f3df45fe907e3385614200a Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 13:01:41 -0700 Subject: [PATCH 20/87] =?UTF-8?q?vc=3D34:=20settings=20batch=20=E2=80=94?= =?UTF-8?q?=20theme,=20cache=20toggle,=20log=20dump,=20reactive=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things land together — same surface, all the data/settings flow: SettingsStore additions themeMode: System / Light / Dark (default System) cacheEnabled: Bool (default true). Single toggle gates both the subs feed cache (FeedCacheStore) and the new search cache. Theme override StrawActivity reads Settings.themeMode and bypasses isSystemInDarkTheme when Light or Dark is chosen. Changes apply immediately via StateFlow recomposition — no restart needed. SearchCacheStore (new) SharedPreferences JSON of the last 30 searches with 20 items each (~150 KB cap). Serialized via @Serializable StreamItem (already serializable since vc=33). Reactive search SearchViewModel.onQueryChange now scans the merged corpus (saved searches + subs feed cache, ~1500 records max) as the user types past 2 chars. Matches on title or uploader (case-insensitive), dedup by URL, cap 60. UI shows a "Cached results · …" hint when the visible list is from cache so users know it's not the network result yet. submit() now paints any matching cached query immediately, then kicks the network. Network results overwrite cache on success and the cached preview survives network failures so offline users still see something. Cache opt-out Settings switch "Enable local cache" wipes both stores when flipped off. FeedCacheVM + SearchVM both short-circuit their read/write paths when the flag is false. Log dump util/LogDump captures this PID's logcat (-d -v threadtime --pid=) to cacheDir, returns a chooser Intent via FileProvider. New Settings → "Export logs…" button. Toast surfaces the failure reason if the dump command itself fails (sandbox-restricted devices etc.). FileProvider declared in manifest with cache-path "logs"; XML at res/xml/file_paths.xml. Authority = ${applicationId}.fileprovider. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/src/main/AndroidManifest.xml | 11 ++ .../kotlin/com/sulkta/straw/StrawActivity.kt | 13 ++- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../com/sulkta/straw/data/SearchCacheStore.kt | 81 +++++++++++++ .../com/sulkta/straw/data/SettingsStore.kt | 29 +++++ .../feature/feed/SubscriptionFeedViewModel.kt | 12 +- .../straw/feature/search/SearchScreen.kt | 19 ++- .../straw/feature/search/SearchViewModel.kt | 90 +++++++++++++- .../straw/feature/settings/SettingsScreen.kt | 110 ++++++++++++++++++ .../kotlin/com/sulkta/straw/util/LogDump.kt | 76 ++++++++++++ strawApp/src/main/res/xml/file_paths.xml | 8 ++ 12 files changed, 443 insertions(+), 12 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt create mode 100644 strawApp/src/main/res/xml/file_paths.xml diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9319ebcd7..1b854d719 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 33 -const val STRAW_VERSION_NAME = "0.1.0-AS" +const val STRAW_VERSION_CODE = 34 +const val STRAW_VERSION_NAME = "0.1.0-AT" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 87ff57336..d3d86d3e0 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -60,5 +60,16 @@ + + + + + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 2a5cc65e9..86bc9f577 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -25,6 +25,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.media3.common.util.UnstableApi +import com.sulkta.straw.data.Settings +import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.channel.ChannelScreen import com.sulkta.straw.feature.detail.VideoDetailScreen import com.sulkta.straw.feature.download.DownloadsScreen @@ -67,7 +69,16 @@ class StrawActivity : ComponentActivity() { val startUrl = pickYouTubeUrl(intent) setContent { - val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors() + // Theme picker: System follows OS, Light/Dark force the + // matching scheme regardless of system setting. + val themeMode by Settings.get().themeMode.collectAsState() + val systemDark = isSystemInDarkTheme() + val dark = when (themeMode) { + ThemeMode.System -> systemDark + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val scheme = if (dark) strawDarkColors() else strawLightColors() // One MediaController for the whole activity. Every screen pulls // it via LocalStrawController; the minibar overlay below uses it // too. Single player, single source of truth. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 95be0a44e..df0e041e1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -9,6 +9,7 @@ import android.app.Application import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.Playlists +import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions @@ -25,5 +26,6 @@ class StrawApp : Application() { Subscriptions.init(this) Playlists.init(this) FeedCache.init(this) + SearchCache.init(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt new file mode 100644 index 000000000..7dc05cc62 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Search-result cache. Holds the last N executed queries and their + * result lists so: + * - Re-running a recent query paints from cache in one frame. + * - Reactive-as-you-type filtering can scan all cached items as + * the user types, surfacing matches before they hit Search. + * + * Sized for SharedPreferences: 30 queries * 20 items each * ~250 bytes + * = ~150 KB worst case. + * + * Skips entirely when Settings.cacheEnabled is false — caller checks + * the flag before reading/writing. + */ + +package com.sulkta.straw.data + +import android.content.Context +import android.content.SharedPreferences +import com.sulkta.straw.feature.search.StreamItem +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class SearchCacheEntry( + val query: String, + val fetchedAt: Long, + val items: List, +) + +private const val PREFS = "straw_search_cache" +private const val KEY = "search_v1" +private const val MAX_QUERIES = 30 +private const val MAX_ITEMS_PER_QUERY = 20 + +class SearchCacheStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + fun load(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(s) + }.getOrDefault(emptyList()) + + /** + * Record a freshly-fetched query result. Idempotent: a re-run of + * the same query overwrites the prior entry rather than duplicating. + * Oldest entries fall off when MAX_QUERIES is exceeded. + */ + fun record(query: String, items: List) { + val q = query.trim() + if (q.isEmpty() || items.isEmpty()) return + val capped = items.take(MAX_ITEMS_PER_QUERY) + val now = System.currentTimeMillis() + val current = load() + val without = current.filterNot { it.query.equals(q, ignoreCase = true) } + val next = (listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES) + sp.edit().putString(KEY, json.encodeToString(next)).apply() + } + + fun clear() { + sp.edit().remove(KEY).apply() + } +} + +object SearchCache { + @Volatile private var instance: SearchCacheStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = SearchCacheStore(context.applicationContext) + } + } + } + + fun get(): SearchCacheStore = instance + ?: error("SearchCacheStore not initialized — call SearchCache.init(context)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 484cfdfe8..499ace926 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -37,9 +37,17 @@ enum class MaxResolution(val label: String, val ceiling: Int) { P144("144p", 144), } +enum class ThemeMode(val label: String) { + System("Follow system"), + Light("Light"), + Dark("Dark"), +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_MAX_RES = "max_resolution_v1" +private const val KEY_THEME = "theme_mode_v1" +private const val KEY_CACHE_ENABLED = "cache_enabled_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -50,6 +58,12 @@ class SettingsStore(context: Context) { private val _maxResolution = MutableStateFlow(loadMaxResolution()) val maxResolution: StateFlow = _maxResolution.asStateFlow() + private val _themeMode = MutableStateFlow(loadThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true)) + val cacheEnabled: StateFlow = _cacheEnabled.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -63,6 +77,16 @@ class SettingsStore(context: Context) { sp.edit().putString(KEY_MAX_RES, r.name).apply() } + fun setThemeMode(t: ThemeMode) { + _themeMode.value = t + sp.edit().putString(KEY_THEME, t.name).apply() + } + + fun setCacheEnabled(enabled: Boolean) { + _cacheEnabled.value = enabled + sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { @@ -77,6 +101,11 @@ class SettingsStore(context: Context) { val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto return MaxResolution.entries.firstOrNull { it.name == name } ?: MaxResolution.Auto } + + private fun loadThemeMode(): ThemeMode { + val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System + return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System + } } object Settings { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 03eb764f7..2503a828c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.FeedCacheEntry +import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW @@ -69,7 +70,9 @@ class SubscriptionFeedViewModel : ViewModel() { // round-trip. The refresh that follows replaces stale entries // in-place — items animate to their new positions via LazyColumn // key stability (URLs are stable across fetches). - val saved = FeedCache.get().load() + // Skip the hydrate when the user has disabled caching — they + // explicitly don't want disk usage for this. + val saved = if (Settings.get().cacheEnabled.value) FeedCache.get().load() else emptyMap() if (saved.isNotEmpty()) { channelCache.putAll(saved) val channels = Subscriptions.get().subs.value @@ -149,8 +152,11 @@ class SubscriptionFeedViewModel : ViewModel() { // Persist what we just freshened. Off the main thread — // JSON encode on 30 subs * 30 items is small but not // free, and SharedPreferences.apply is async anyway. - withContext(Dispatchers.IO) { - runCatching { FeedCache.get().save(channelCache.toMap()) } + // Skipped entirely when the user has disabled caching. + if (Settings.get().cacheEnabled.value) { + withContext(Dispatchers.IO) { + runCatching { FeedCache.get().save(channelCache.toMap()) } + } } } catch (t: Throwable) { _ui.update { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index a6809ebc4..fce5263c0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -116,10 +116,21 @@ fun SearchScreen( contentAlignment = Alignment.Center, ) { Text("hit enter to search") } - else -> LazyColumn(modifier = Modifier.fillMaxSize()) { - items(state.results) { item -> - ResultRow(item = item) { onOpenVideo(item.url, item.title) } - HorizontalDivider() + else -> Column(modifier = Modifier.fillMaxSize()) { + if (state.fromCache) { + Text( + text = if (state.loading) "Cached results · refreshing…" + else "Cached results · hit Search for fresh", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.results) { item -> + ResultRow(item = item) { onOpenVideo(item.url, item.title) } + HorizontalDivider() + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 0b3cc78b3..a816c18b2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -7,17 +7,28 @@ package com.sulkta.straw.feature.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History +import com.sulkta.straw.data.SearchCache +import com.sulkta.straw.data.Settings +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext data class SearchUiState( val query: String = "", val results: List = emptyList(), val loading: Boolean = false, val error: String? = null, + /** + * True when the visible results came from the local cache and we + * have not yet replaced them with a network response. Lets the UI + * show a faint "from cache" hint without blocking the list. + */ + val fromCache: Boolean = false, ) @kotlinx.serialization.Serializable @@ -40,12 +51,53 @@ class SearchViewModel : ViewModel() { fun onQueryChange(q: String) { _ui.value = _ui.value.copy(query = q) + // Reactive filter: scan every cached item (search-cache + subs + // feed-cache) as the user types. Cheap, runs in-memory, gives + // instant feedback before they hit Enter. Disabled when the + // user has turned off the cache feature. + if (Settings.get().cacheEnabled.value && q.trim().length >= 2) { + val matches = reactiveFilter(q.trim()) + if (matches.isNotEmpty()) { + _ui.value = _ui.value.copy( + results = matches, + fromCache = true, + loading = false, + error = null, + ) + } + } else if (q.isBlank()) { + // Clear cached preview if the box is cleared. + _ui.value = _ui.value.copy(results = emptyList(), fromCache = false) + } } fun submit() { val q = _ui.value.query.trim() if (q.isEmpty()) return - _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList()) + + // Cache hit on submit: show immediately, kick off a refresh + // behind it so the user gets fresh items shortly after. + val cached = if (Settings.get().cacheEnabled.value) { + SearchCache.get().load() + .firstOrNull { it.query.equals(q, ignoreCase = true) } + ?.items + } else null + if (cached != null && cached.isNotEmpty()) { + _ui.value = _ui.value.copy( + loading = true, + error = null, + results = cached, + fromCache = true, + ) + } else { + _ui.value = _ui.value.copy( + loading = true, + error = null, + results = emptyList(), + fromCache = false, + ) + } + viewModelScope.launch { try { // strawcore.search() is suspend on the tokio runtime baked @@ -63,11 +115,22 @@ class SearchViewModel : ViewModel() { uploadDateRelative = r.uploadDateRelative, ) } - _ui.value = _ui.value.copy(loading = false, results = items) + _ui.value = _ui.value.copy( + loading = false, + results = items, + fromCache = false, + ) // Record AFTER the search succeeds so mistyped queries // that error out don't pollute the recent-searches list. runCatching { History.get().recordSearch(q) } + if (Settings.get().cacheEnabled.value) { + withContext(Dispatchers.IO) { + runCatching { SearchCache.get().record(q, items) } + } + } } catch (t: Throwable) { + // Keep the cached preview visible on network failure so + // the user still has something to look at while offline. _ui.value = _ui.value.copy( loading = false, error = t.message ?: t.javaClass.simpleName, @@ -75,4 +138,27 @@ class SearchViewModel : ViewModel() { } } } + + /** + * Walk the merged corpus of cached items (every saved search + + * every subs-feed channel cache) and return items whose title or + * uploader contains the query — case-insensitive, dedup by URL. + * Cheap: even with 30 cached queries * 20 items + 30 channels * 30 + * items it's < 1500 records, plenty fast for an in-memory filter. + */ + private fun reactiveFilter(q: String): List { + val needle = q.lowercase() + val pool = buildList { + SearchCache.get().load().forEach { addAll(it.items) } + FeedCache.get().load().values.forEach { addAll(it.items) } + } + return pool.asSequence() + .filter { item -> + item.title.lowercase().contains(needle) + || item.uploader.lowercase().contains(needle) + } + .distinctBy { it.url } + .take(60) + .toList() + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index f3e704b0d..1f8339ca8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -40,12 +40,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import android.widget.Toast +import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.SbCategory +import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings +import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.util.LogDump import kotlinx.coroutines.launch @Composable @@ -134,6 +139,81 @@ fun SettingsScreen() { HorizontalDivider() } + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Appearance", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Light, dark, or follow the system setting.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + val theme by store.themeMode.collectAsState() + ThemeMode.entries.forEach { t -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { store.setThemeMode(t) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (t == theme) "• ${t.label}" else " ${t.label}", + style = MaterialTheme.typography.bodyLarge, + color = if (t == theme) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider() + } + + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Local cache", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Caches the subs feed and recent searches on disk so the app " + + "paints instantly on cold start and you can search " + + "previously-seen videos with no network. ~400 KB max. " + + "Turn it off to save space on low-storage devices.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + val cacheEnabled by store.cacheEnabled.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Enable local cache", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Switch( + checked = cacheEnabled, + onCheckedChange = { checked -> + store.setCacheEnabled(checked) + if (!checked) { + // Wipe on disable — leaving stale bytes around + // defeats the purpose of opting out. + FeedCache.get().clear() + SearchCache.get().clear() + } + }, + ) + } + Spacer(modifier = Modifier.height(32.dp)) Text( "History", @@ -150,6 +230,36 @@ fun SettingsScreen() { } } + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Diagnostics", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Dump this app's recent logcat to a text file and open the " + + "system share sheet — attach it when reporting an issue.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = { + val outcome = LogDump.capture(context) + outcome.onSuccess { intent -> + context.startActivity(android.content.Intent.createChooser(intent, "Share Straw logs")) + } + outcome.onFailure { t -> + Toast.makeText( + context, + "Log dump failed: ${t.message ?: t.javaClass.simpleName}", + Toast.LENGTH_LONG, + ).show() + } + }) { + Text("Export logs…") + } + Spacer(modifier = Modifier.height(32.dp)) Text( "Import from NewPipe / Tubular", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt new file mode 100644 index 000000000..9b4675aeb --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Capture this process's logcat into a file and return a share Intent. + * Used from the Settings → "Export logs" action so users can attach a + * log dump when reporting a problem. + * + * NOTE: Android limits logcat-via-Runtime.exec to the calling app's + * own UID on API 30+, so this captures Straw's own log lines only + * (plus a sliver of system-wide messages tagged by our PID). No + * other app's logs are exposed. + */ + +package com.sulkta.straw.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Process +import androidx.core.content.FileProvider +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object LogDump { + + /** + * Pull recent logcat, write to a file in cacheDir, return a + * share-able Intent. Caller is responsible for `startActivity`. + * Returns null when the dump command itself fails — the caller + * shows a Toast with the error. + */ + fun capture(context: Context): Result = runCatching { + val pid = Process.myPid() + val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") + + // -d dump-and-exit (no follow), -v threadtime is the + // most-greppable format, and the --pid filter restricts to + // our process so we don't accidentally exfiltrate sibling + // apps' chatter even when the system would allow it. + val cmd = arrayOf( + "logcat", + "-d", + "-v", + "threadtime", + "--pid=$pid", + ) + val proc = ProcessBuilder(*cmd) + .redirectErrorStream(true) + .start() + outFile.outputStream().use { out -> + proc.inputStream.copyTo(out) + } + val exit = proc.waitFor() + if (exit != 0) { + throw IOException("logcat exit=$exit") + } + if (outFile.length() == 0L) { + throw IOException("logcat produced 0 bytes (sandbox restriction?)") + } + + // FileProvider authority — declared in AndroidManifest below. + val authority = "${context.packageName}.fileprovider" + val uri: Uri = FileProvider.getUriForFile(context, authority, outFile) + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } +} diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..92e012475 --- /dev/null +++ b/strawApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,8 @@ + + + + + From e76a325faa6758592d0875d00f1dd852b2b04ae3 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 13:27:30 -0700 Subject: [PATCH 21/87] =?UTF-8?q?vc=3D35:=20audit-fix=20sprint=20=E2=80=94?= =?UTF-8?q?=205=20CRIT=20+=2014=20HIGH=20+=20opportunistic=20MEDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Opus max-effort audits (CVE/security, code-health, function- correctness) on the vc=34 surface returned a consolidated punch list of 5 CRIT + 14 HIGH + ~10 MED. This commit lands all CRIT + HIGH + the cheap MEDs in one cohesive pass. CRIT — privacy + main-thread blocks S1 IosSafeHttpDataSource was logging full pre-signed googlevideo URLs (signature/sig/pot/expire/cpn) via raw android.util.Log.i (no DEBUG gate). Then LogDump.capture would scrape its own PID's logcat and ship it via the share sheet — a "report a bug to Telegram" silently exfiltrated session credentials. Fixed by switching all log calls to strawLogD/strawLogW (gated on BuildConfig.DEBUG), dropping the full-URL log entirely, and adding a regex scrub pass in LogDump for googlevideo URLs + signed-param keys before the file hits disk. S2 Downloader.enqueue handed signed googlevideo URLs to the system DownloadManager, where they leak into DM's SQLite, logcat, the system notification, and apps holding ACCESS_DOWNLOAD_MANAGER. Set VISIBILITY_HIDDEN + setVisibleInDownloadsUi(false) so the URL never surfaces in any external surface. Added the DOWNLOAD_WITHOUT_NOTIFICATION permission DM requires. S3 SettingsImport extracted the user's full newpipe.db (every sub, watch, search) into cacheDir, deleted on finally. A force-kill mid-import left the DB on disk indefinitely. Wrapped cleanup in withContext(NonCancellable), switched workDir to createTempFile (unguessable name), and added StrawApp.onCreate sweep of stale newpipe-import-* dirs on every cold start. C1 SearchViewModel.reactiveFilter ran a fresh SharedPreferences. getString + Json.decodeFromString on FeedCache (~225 KB) AND SearchCache (~150 KB) on EVERY keystroke. Hoisted into a MutableStateFlow> pool, built once on Dispatchers.IO at VM init and refreshed after each successful submit. Reactive filter now walks an in-memory list. C2 SubscriptionFeedViewModel.init did the same FeedCache.load() synchronously on the main thread at construction (first compose pass blocked on the JSON decode). Moved into viewModelScope.launch + withContext(Dispatchers.IO). HIGH — function correctness + defense in depth B1+B2 SearchScreen when-branch order: loading + error short- circuited before the results branch, hiding the cached preview the VM explicitly kept visible on cache-hit + on network failure. Refactored to render the cached list under a thin progress bar / error banner instead. B3 Downloads "tap completed row" silently failed since minSdk 24 (FileUriExposedException on the file:// URI). Route through FileProvider with new file_paths.xml entries for Movies/audio + Movies/video. B6 Manifest VIEW intent-filter was missing music.youtube.com and youtube-nocookie.com hosts even though YT_HOSTS allowed them — added both. B7 SponsorBlockSkipLoop fired one Toast per skip with no rate limit; sponsor-dense videos painted 20+ Toasts over 40s after the seeks completed. 3s rate limit per cur.streamUrl. Q8 resolvePlayback.pickVideo fallback used maxByOrNull when the comment said "lowest available" — a 480p-capped user on a 1080p-only upload got 1080p (their data cap blown). Switched to minByOrNull { height } when nothing fits the cap. S1 SettingsImport extractZip had no size or entry-count caps — zip-bomb could fill cacheDir. Added MAX_DB_BYTES (256 MB), MAX_PREFS_BYTES (1 MB), MAX_ZIP_ENTRIES (64). copyBounded / readBoundedBytes helpers replace the unbounded copyTo / readBytes. S2 Manifest: android:allowBackup=false + android:dataExtractionRules=@xml/data_extraction_rules.xml + android:fullBackupContent=false. Excludes root/file/database/ sharedpref/external from both cloud-backup and device-transfer so the user's full search + watch history doesn't ride to Google Drive. S3 Dropped isLenient = true from every Json {} instance (7 sites across data/ and net/). Lenient parser was buying nothing on data we wrote ourselves and was a hardening gap on the third- party SponsorBlock + RYD endpoints (community-run; malformed payload could feed bad timestamps into the skip loop). S4 SubscriptionsStore.addAll + HistoryStore.recordAllWatches bulk methods, used by SettingsImport. Per-row toggle was O(N²) + N SP writes; bulk path is O(N) + 1 write. C3 SubsPane infinite-scroll LaunchedEffect keyed on (displayed.size, hasMore) — both mutated BY the effect. The collector cancelled itself mid-stream and dropped emissions, producing "scroll to bottom, nothing more loads". Re-keyed on listState + filteredCount; the collect lambda reads state through the snapshotFlow producer to avoid stale captures. C7 liveDrag + playbackSpeed: mutableFloatStateOf instead of mutableStateOf — no Float boxing on the 100Hz drag callback. C8 LogDump.capture is now suspend on Dispatchers.IO. The Settings click handler launches into scope; button shows "Exporting…" while in flight. MED — cheap wins picked up in passing Q9 reactiveFilter clears the cached preview when current query no longer has any matches (was leaving stale results visible). Q10 Hide-watched filter excludes blank video IDs from watchedIds — a blank in the set used to match every malformed-URL feed item and silently hide them. Q13 PiP button in VideoDetail bails with a Toast on null controller OR null resolved playback (was falling through to enterPictureInPictureMode with no stream). C17 SpeedPickerDialog row used fillMaxSize inside an AlertDialog; only the first row got non-zero height. Fixed to fillMaxWidth. Deferred to vc=36 follow-up (touch surface area we don't want to churn in the same ship): - C6 atomic setPlayingFrom guard in StrawMediaController - S3 (full) — direct-streaming download replacing DownloadManager - MED-C16 LazyColumn refactor in VideoDetailScreen - MED-Q12 loadedUrl assignment ordering hardening --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/src/main/AndroidManifest.xml | 16 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 8 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 6 + .../main/kotlin/com/sulkta/straw/StrawHome.kt | 29 +++- .../com/sulkta/straw/data/FeedCacheStore.kt | 2 +- .../com/sulkta/straw/data/HistoryStore.kt | 26 +++- .../com/sulkta/straw/data/PlaylistsStore.kt | 2 +- .../com/sulkta/straw/data/SearchCacheStore.kt | 2 +- .../sulkta/straw/data/SubscriptionsStore.kt | 27 +++- .../feature/dataimport/SettingsImport.kt | 137 ++++++++++++++---- .../straw/feature/detail/VideoDetailScreen.kt | 35 +++-- .../feature/detail/VideoDetailViewModel.kt | 19 ++- .../straw/feature/download/Downloader.kt | 15 +- .../straw/feature/download/DownloadsScreen.kt | 21 ++- .../feature/feed/SubscriptionFeedViewModel.kt | 16 +- .../straw/feature/player/PlayerScreen.kt | 15 +- .../straw/feature/search/SearchScreen.kt | 25 +++- .../straw/feature/search/SearchViewModel.kt | 55 +++++-- .../straw/feature/settings/SettingsScreen.kt | 38 +++-- .../sulkta/straw/net/IosSafeHttpDataSource.kt | 25 ++-- .../kotlin/com/sulkta/straw/net/RydClient.kt | 2 +- .../sulkta/straw/net/SponsorBlockClient.kt | 2 +- .../kotlin/com/sulkta/straw/util/LogDump.kt | 128 ++++++++++------ .../main/res/xml/data_extraction_rules.xml | 28 ++++ strawApp/src/main/res/xml/file_paths.xml | 14 +- 26 files changed, 531 insertions(+), 166 deletions(-) create mode 100644 strawApp/src/main/res/xml/data_extraction_rules.xml diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 1b854d719..4ba83af96 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 34 -const val STRAW_VERSION_NAME = "0.1.0-AT" +const val STRAW_VERSION_CODE = 35 +const val STRAW_VERSION_NAME = "0.1.0-AU" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index d3d86d3e0..dff4e3cc1 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -11,12 +11,20 @@ + + + - + @@ -39,6 +50,9 @@ + + + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 86bc9f577..ead83af3f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -207,7 +207,13 @@ class StrawActivity : ComponentActivity() { // Explicit scheme + host check — defense in depth vs the // manifest intent-filter; apps can synth intents that // bypass filter scheme matching on exported activities. - if (intent.scheme?.lowercase() !in setOf("https", "http")) return null + // HTTPS only — matches the manifest VIEW filter so an explicit + // ComponentName intent can't smuggle an http:// URL past the + // filter check. Defense in depth; the YT_URL_RE still allows + // http for the ACTION_SEND substring case where the URL is + // embedded in attacker-controlled text and we want to match + // common share-sheet links, but VIEW must be tighter. + if (intent.scheme?.lowercase() != "https") return null if (!looksLikeYouTube(data)) return null data } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index df0e041e1..a6ae34c13 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -12,6 +12,7 @@ import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions +import com.sulkta.straw.feature.dataimport.SettingsImport class StrawApp : Application() { override fun onCreate() { @@ -27,5 +28,10 @@ class StrawApp : Application() { Playlists.init(this) FeedCache.init(this) SearchCache.init(this) + // Sweep any newpipe-import-* work-dirs left in cacheDir by a + // previous import that was killed mid-extraction. CRIT from + // the vc=34 security audit — the user's full NewPipe DB would + // otherwise live in cacheDir until the next deleteRecursively. + SettingsImport.sweepStale(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index dd23aeae4..ff2d26d34 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -280,8 +280,12 @@ private fun SubsPane( var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) } // O(1) lookup for the watched-filter; rebuild only when watches - // change. Just the video IDs because URLs vary by tracking params. - val watchedIds = remember(watches) { watches.map { it.videoId }.toSet() } + // change. Drop blank IDs — `recordWatch` doesn't gate on those, + // and a blank in the set would `extractVideoId(url)=""` match + // EVERY malformed-URL item and silently hide them all. + val watchedIds = remember(watches) { + watches.map { it.videoId }.filter { it.isNotBlank() }.toSet() + } val filteredItems = remember(feed.items, hideWatched, watchedIds) { if (!hideWatched) feed.items @@ -388,11 +392,24 @@ private fun SubsPane( lastVisible >= info.totalItemsCount - 5 } } - LaunchedEffect(displayed.size, hasMore) { - snapshotFlow { nearBottom }.collect { atEnd -> - if (atEnd && hasMore) { + // Key on listState only — the previous key set + // (displayed.size, hasMore) was mutated BY this effect, + // which cancelled the snapshotFlow collector mid-stream + // and produced the "scrolled to bottom, nothing loads" + // bug from the vc=34 audit. + // + // hasMore and filteredItems are read inside the + // snapshotFlow producer (not closed over from outside) + // so Compose re-reads them on each frame instead of + // capturing the stale value at lambda-creation time. + val filteredCount = filteredItems.size + LaunchedEffect(listState, filteredCount) { + snapshotFlow { + nearBottom && visibleCount < filteredCount + }.collect { shouldGrow -> + if (shouldGrow) { visibleCount = (visibleCount + PAGE_SIZE) - .coerceAtMost(filteredItems.size) + .coerceAtMost(filteredCount) } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index 70d9a2c1b..0464d25a3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -36,7 +36,7 @@ private const val KEY = "cache_v1" class FeedCacheStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } /** Snapshot of the disk cache. Returns empty map if nothing saved. */ fun load(): Map = runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index cc04a788c..7ecc397d7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -36,7 +36,7 @@ private const val MAX_SEARCHES = 20 class HistoryStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } private val _watches = MutableStateFlow(loadWatches()) val watches: StateFlow> = _watches.asStateFlow() @@ -56,6 +56,30 @@ class HistoryStore(context: Context) { sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() } + /** + * Bulk import. Callers (currently SettingsImport) feed + * oldest→newest so the most-recent entries end up at the front + * of the capped list. Single SP write — vc=34 audit flagged the + * per-row recordWatch in importHistory as a write-storm vector. + */ + fun recordAllWatches(items: List) { + if (items.isEmpty()) return + val next = _watches.updateAndGet { current -> + val seen = current.map { it.videoId }.toMutableSet() + val merged = current.toMutableList() + for (item in items) { + if (item.videoId.isBlank()) continue + if (item.videoId in seen) { + merged.removeAll { it.videoId == item.videoId } + } + seen.add(item.videoId) + merged.add(0, item) + } + merged.take(MAX_WATCHES) + } + sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() + } + fun recordSearch(query: String) { val q = query.trim() if (q.isEmpty()) return diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt index cf72bd597..57f8979f6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -47,7 +47,7 @@ private const val KEY = "playlists_v1" class PlaylistsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } private val _playlists = MutableStateFlow(load()) val playlists: StateFlow> = _playlists.asStateFlow() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index 7dc05cc62..10f5a961d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -37,7 +37,7 @@ private const val MAX_ITEMS_PER_QUERY = 20 class SearchCacheStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } fun load(): List = runCatching { val s = sp.getString(KEY, null) ?: return emptyList() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 303ac4678..e4d897927 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -29,7 +29,7 @@ private const val KEY = "subs_v1" class SubscriptionsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } private val _subs = MutableStateFlow(load()) val subs: StateFlow> = _subs.asStateFlow() @@ -64,6 +64,31 @@ class SubscriptionsStore(context: Context) { persist(next) } + /** + * Bulk-add. Single persist instead of N. Per-call `toggle()` was + * O(N²) + N SP writes, which the vc=34 security audit flagged as + * a DoS vector for hostile NewPipe-export imports. Single linear + * scan to dedup, one persist regardless of input size. Returns the + * count of NEW (not previously-subscribed) channels added so the + * caller can report an "added X" stat. + */ + fun addAll(refs: List): Int { + var added = 0 + val next = _subs.updateAndGet { cur -> + val byUrl = cur.associateBy { it.url }.toMutableMap() + for (r in refs) { + if (r.url.isBlank()) continue + if (r.url !in byUrl) { + byUrl[r.url] = r + added++ + } + } + byUrl.values.toList() + } + persist(next) + return added + } + fun clear() { // Same atomic-update path as toggle — protects against a concurrent // toggle racing the clear and persisting [new-item] after the diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index 4e138788e..f2bab6d72 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -38,6 +38,7 @@ import com.sulkta.straw.data.WatchHistoryItem import java.io.File import java.util.zip.ZipInputStream import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -106,10 +107,29 @@ object SettingsImport { runCatching { runInner(context, zipUri) } } - private fun runInner(context: Context, zipUri: Uri): ImportResult { + /** + * Sweep stale import work-dirs left behind by a previous run that + * was killed mid-extraction. CRIT from the vc=34 security audit: + * a force-killed import leaves the user's full newpipe.db sitting + * in cacheDir indefinitely. StrawApp.onCreate calls this on every + * cold start. + */ + fun sweepStale(context: Context) { + runCatching { + context.cacheDir.listFiles { f -> + f.isDirectory && f.name.startsWith("newpipe-import-") + }?.forEach { it.deleteRecursively() } + } + } + + private suspend fun runInner(context: Context, zipUri: Uri): ImportResult { val warnings = mutableListOf() - val workDir = File(context.cacheDir, "newpipe-import-${System.currentTimeMillis()}") - workDir.mkdirs() + // createTempFile returns an unguessable name and 0600 perms by + // default, replacing the predictable currentTimeMillis suffix + // that an attacker could pre-create a symlink at. + val workDir = File.createTempFile("newpipe-import-", "", context.cacheDir).also { + it.delete(); it.mkdirs() + } try { val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings) @@ -132,10 +152,22 @@ object SettingsImport { warnings = warnings, ) } finally { - workDir.deleteRecursively() + // NonCancellable guarantees the cleanup runs even when the + // outer coroutine was cancelled — without it a user + // navigating away mid-import (or low-memory killer firing) + // left the full newpipe.db in cacheDir until the next + // cold-start sweep. + withContext(NonCancellable) { + workDir.deleteRecursively() + } } } + // Defense against zip-bomb / malformed exports. + private const val MAX_DB_BYTES: Long = 256L * 1024 * 1024 + private const val MAX_PREFS_BYTES: Long = 1L * 1024 * 1024 + private const val MAX_ZIP_ENTRIES: Int = 64 + private fun extractZip( context: Context, zipUri: Uri, @@ -144,24 +176,37 @@ object SettingsImport { ): Pair { var dbFile: File? = null var prefs: JsonObject? = null + var entryCount = 0 context.contentResolver.openInputStream(zipUri)?.use { input -> ZipInputStream(input).use { zip -> while (true) { val entry = zip.nextEntry ?: break + entryCount++ + if (entryCount > MAX_ZIP_ENTRIES) { + warnings += "archive has >$MAX_ZIP_ENTRIES entries — aborting" + return null to null + } when (entry.name) { "newpipe.db" -> { val out = File(workDir, "newpipe.db") - out.outputStream().use { os -> - zip.copyTo(os, bufferSize = 64 * 1024) + val written = copyBounded(zip, out, MAX_DB_BYTES) + if (written < 0L) { + warnings += "newpipe.db exceeds ${MAX_DB_BYTES / (1024 * 1024)} MB — aborting" + out.delete() + return null to null } dbFile = out } "preferences.json" -> { - val bytes = zip.readBytes() - prefs = runCatching { - Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject - }.getOrNull() - if (prefs == null) warnings += "preferences.json present but unparseable" + val bytes = readBoundedBytes(zip, MAX_PREFS_BYTES) + if (bytes == null) { + warnings += "preferences.json exceeds ${MAX_PREFS_BYTES / 1024} KB — skipping" + } else { + prefs = runCatching { + Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject + }.getOrNull() + if (prefs == null) warnings += "preferences.json present but unparseable" + } } // newpipe.settings is the legacy XML form; preferences.json // supersedes it in every modern export. Skip. @@ -176,14 +221,51 @@ object SettingsImport { return dbFile to prefs } + /** + * Bounded copy. Returns bytes-written on success, -1 if `cap` was + * exceeded. Used instead of `copyTo` so a 16 GB zip-bomb doesn't + * fill the user's cacheDir before we notice. + */ + private fun copyBounded(src: java.io.InputStream, dst: File, cap: Long): Long { + dst.outputStream().use { os -> + val buf = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = src.read(buf) + if (n <= 0) break + total += n + if (total > cap) return -1L + os.write(buf, 0, n) + } + return total + } + } + + private fun readBoundedBytes(src: java.io.InputStream, cap: Long): ByteArray? { + val baos = java.io.ByteArrayOutputStream() + val buf = ByteArray(16 * 1024) + var total = 0L + while (true) { + val n = src.read(buf) + if (n <= 0) break + total += n + if (total > cap) return null + baos.write(buf, 0, n) + } + return baos.toByteArray() + } + private data class SubsResult(val added: Int, val skipped: Int) private fun importSubscriptions(dbFile: File): SubsResult { val store = Subscriptions.get() - var added = 0 + // Cap input row count too — hostile NewPipe export with a + // million rows would still walk the cursor fully without this. + val maxRows = 10_000 var skipped = 0 + val staged = mutableListOf() openDb(dbFile).use { db -> db.rawQuery( - "SELECT url, name, avatar_url, service_id FROM subscriptions", + "SELECT url, name, avatar_url, service_id FROM subscriptions LIMIT $maxRows", null, ).use { c -> while (c.moveToNext()) { @@ -195,13 +277,12 @@ object SettingsImport { val url = c.getString(0) ?: continue val name = c.getString(1) ?: continue val avatar = c.getString(2) - if (!store.isSubscribed(url)) { - store.toggle(ChannelRef(url = url, name = name, avatar = avatar)) - added++ - } + staged += ChannelRef(url = url, name = name, avatar = avatar) } } } + // Single dedup + single persist regardless of N. + val added = store.addAll(staged) return SubsResult(added, skipped) } @@ -293,12 +374,17 @@ object SettingsImport { db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c -> if (c.moveToNext()) watchesAvailable = c.getInt(0) } + // Stage rows in memory, then one bulk write — same DoS + // mitigation as importSubscriptions. recordWatch did N SP + // writes and an O(N) dedup per row. + val staged = mutableListOf() db.rawQuery( """ SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id FROM stream_history h JOIN streams s ON s.uid = h.stream_id ORDER BY h.access_date ASC + LIMIT 50000 """.trimIndent(), null, ).use { c -> @@ -309,19 +395,18 @@ object SettingsImport { val uploader = c.getString(2) ?: "" val thumb = c.getString(3) val videoId = extractYtVideoId(url) ?: continue - historyStore.recordWatch( - WatchHistoryItem( - url = url, - videoId = videoId, - title = title, - uploader = uploader, - thumbnail = thumb, - watchedAt = c.getLong(4), - ), + staged += WatchHistoryItem( + url = url, + videoId = videoId, + title = title, + uploader = uploader, + thumbnail = thumb, + watchedAt = c.getLong(4), ) watchesSeen++ } } + historyStore.recordAllWatches(staged) // Resume positions — counted, not stored. Future task hooks into // a ResumePositionsStore. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 59f48f148..8e6304966 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -155,7 +156,9 @@ fun VideoDetailScreen( val dismissThresholdPx = with(density) { 140.dp.toPx() } val flingVelocityThreshold = with(density) { 600.dp.toPx() } val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } - var liveDrag by remember { mutableStateOf(0f) } + // mutableFloatStateOf avoids boxing on every drag delta — the + // draggable callback fires 100+ times/s on a fast swipe. + var liveDrag by remember { mutableFloatStateOf(0f) } var dragging by remember { mutableStateOf(false) } val releaseAnim = remember { Animatable(0f) } val draggableState = rememberDraggableState { delta -> @@ -385,21 +388,23 @@ fun VideoDetailScreen( Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() return@OutlinedButton } - // PiP needs the controller to actually be playing - // this video, same as Background — otherwise we - // pop out into nothing. + // PiP into nothing isn't useful — bail with a + // Toast if there's no controller / no resolved + // playback to push into it. vc=34 audit Q-13. val c = controller - if (c != null && NowPlaying.current.value?.streamUrl != streamUrl) { - val r = state.resolved - if (r != null) { - c.setPlayingFrom( - streamUrl = streamUrl, - title = d.title, - uploader = d.uploader, - thumbnail = d.thumbnail, - resolved = r, - ) - } + val r = state.resolved + if (c == null || r == null) { + Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() + return@OutlinedButton + } + if (NowPlaying.current.value?.streamUrl != streamUrl) { + c.setPlayingFrom( + streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, + resolved = r, + ) } val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(16, 9)) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index e269b3f6c..19e2b2fb5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -195,13 +195,22 @@ class VideoDetailViewModel : ViewModel() { segments: List, ): ResolvedPlayback { val maxRes = Settings.get().maxResolution.value.ceiling - // Filter by max-resolution ceiling but fall back to the lowest - // available if the ceiling excludes everything (e.g. a 360p-only - // upload with the user on a 480p cap). + // Pick the highest-bitrate stream that still fits the user's + // cap. Fallback: when every available stream EXCEEDS the cap + // (e.g. a 1080p-only upload with the user on a 480p cap), pick + // the LOWEST-height one — that's the closest-to-cap option and + // honors the user's intent ("don't blow my data plan") even + // when their exact target isn't available. vc=34 audit Q-8 — + // previously this fell back to max-bitrate, which was the + // worst possible choice for someone on a 480p cap. fun pickVideo(streams: List): String? { if (streams.isEmpty()) return null - val pool = streams.filter { it.height <= maxRes }.ifEmpty { streams } - return pool.maxByOrNull { it.bitrate }?.url + val capped = streams.filter { it.height <= maxRes } + return if (capped.isNotEmpty()) { + capped.maxByOrNull { it.bitrate }?.url + } else { + streams.minByOrNull { it.height }?.url + } } return ResolvedPlayback( title = info.title, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt index bdb7dcce3..6ce34060e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt @@ -51,11 +51,24 @@ object Downloader { val filename = "$safeTitle${kind.ext}" val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + // SECURITY: pre-signed googlevideo URLs leak to anything reading + // DownloadManager state (system notification stack, downloads UI, + // apps with ACCESS_DOWNLOAD_MANAGER). We can't hide the URL from + // DM itself without re-implementing the download, but we can hide + // it from every surface DM forwards to: + // setNotificationVisibility(HIDDEN) — no system notification + // surfaces the URL via tap-to-open / accessibility scrapers. + // setVisibleInDownloadsUi(false) — the Downloads system app + // won't list this entry, so a user opening Files / Downloads + // can't long-press → details → see the URL. + // Our own DownloadsScreen reads progress out of DM via the ID + // returned below, so user-facing UX is unaffected. val req = runCatching { DownloadManager.Request(Uri.parse(url)) .setTitle(title) .setDescription("Straw — ${kind.name.lowercase()}") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + .setVisibleInDownloadsUi(false) .setAllowedOverMetered(true) .setAllowedOverRoaming(true) .setDestinationInExternalFilesDir( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt index 5872a5c17..30ec8cc94 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -156,8 +156,27 @@ private fun DownloadRowView( .fillMaxWidth() .clickable(enabled = openable) { row.localUri?.let { uri -> + // DownloadManager returns a file:// URI for the + // setDestinationInExternalFilesDir target. Passing + // that across an app boundary throws + // FileUriExposedException on every API >= 24 since + // minSdk 24. Route through FileProvider so the + // receiver gets a grantable content:// URI instead. + val shareUri = runCatching { + val src = Uri.parse(uri) + val path = src.path + if (src.scheme == "file" && path != null) { + androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + java.io.File(path), + ) + } else { + src + } + }.getOrNull() ?: return@let val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*") + setDataAndType(shareUri, row.mediaType ?: "*/*") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } runCatching { context.startActivity(intent) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 2503a828c..fbdff939b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -66,14 +66,14 @@ class SubscriptionFeedViewModel : ViewModel() { init { // Hydrate from disk and immediately render the cached items so - // the Subs tab paints in one frame instead of after the network - // round-trip. The refresh that follows replaces stale entries - // in-place — items animate to their new positions via LazyColumn - // key stability (URLs are stable across fetches). - // Skip the hydrate when the user has disabled caching — they - // explicitly don't want disk usage for this. - val saved = if (Settings.get().cacheEnabled.value) FeedCache.get().load() else emptyMap() - if (saved.isNotEmpty()) { + // the Subs tab paints before the network round-trip resolves. + // vc=34 audit CRIT: previously this ran synchronously on the + // main thread at VM construction, blocking the first compose + // pass on a ~225 KB Json.decodeFromString. + viewModelScope.launch { + if (!Settings.get().cacheEnabled.value) return@launch + val saved = withContext(Dispatchers.IO) { FeedCache.get().load() } + if (saved.isEmpty()) return@launch channelCache.putAll(saved) val channels = Subscriptions.get().subs.value if (channels.isNotEmpty()) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 0c3ee8f01..47eccf046 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -52,6 +52,7 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -89,7 +90,7 @@ fun PlayerScreen( val state by vm.ui.collectAsStateWithLifecycle() LaunchedEffect(streamUrl) { vm.load(streamUrl) } - var playbackSpeed by remember { mutableStateOf(1.0f) } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var audioOnly by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) } @@ -301,7 +302,7 @@ private fun SpeedPickerDialog( options.forEach { s -> Row( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .clickable { onPick(s) } .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, @@ -341,6 +342,10 @@ fun SponsorBlockSkipLoop() { val segments = cur.segments if (segments.isEmpty() || controller == null) return val skipped = remember(cur.streamUrl) { mutableSetOf() } + // Rate-limit the skip Toast — back-to-back segments in + // sponsor-dense videos used to queue 20+ Toasts that paint over + // the screen for 40s after the actual seek (vc=34 audit HIGH-B7). + var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) } LaunchedEffect(cur.streamUrl, controller) { while (true) { delay(150) @@ -360,7 +365,11 @@ fun SponsorBlockSkipLoop() { controller.seekTo(targetMs) } s.UUID?.let { skipped.add(it) } - Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show() + val now = System.currentTimeMillis() + if (now - lastToastAt > 3000) { + Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show() + lastToastAt = now + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index fce5263c0..c0c7a3434 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -69,12 +69,19 @@ fun SearchScreen( Spacer(modifier = Modifier.height(12.dp)) when { - state.loading -> Box( + // Loading WITH cached results: thin progress bar above the + // list, results stay visible. vc=34 audit B-1 — the prior + // order short-circuited to a centered spinner and hid the + // cached preview the VM was trying to show. + state.loading && state.results.isEmpty() -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } - state.error != null -> Box( + // Error WITH cached results: thin error banner above the + // list. Audit B-2 — error branch used to clobber the + // cached preview the VM explicitly kept. + state.error != null && state.results.isEmpty() -> Box( modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center, ) { @@ -117,6 +124,20 @@ fun SearchScreen( ) { Text("hit enter to search") } else -> Column(modifier = Modifier.fillMaxSize()) { + if (state.loading) { + androidx.compose.material3.LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + } + if (state.error != null) { + Text( + text = "refresh failed: ${state.error}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 4.dp), + ) + } if (state.fromCache) { Text( text = if (state.loading) "Cached results · refreshing…" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index a816c18b2..6cb952a1f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -49,12 +49,34 @@ class SearchViewModel : ViewModel() { private val _ui = MutableStateFlow(SearchUiState()) val ui: StateFlow = _ui.asStateFlow() + /** + * In-memory snapshot of the disk corpus (saved search results + + * subs feed cache) for reactive filtering. Hydrated on Dispatchers.IO + * once at VM construction and refreshed after a successful submit. + * vc=34 audit CRIT — the previous implementation hit + * SharedPreferences + JSON-decoded ~225 KB on every keystroke, + * blocking the main thread. + */ + private val pool = MutableStateFlow>(emptyList()) + + init { + viewModelScope.launch { + if (Settings.get().cacheEnabled.value) { + pool.value = withContext(Dispatchers.IO) { buildPool() } + } + } + } + + private fun buildPool(): List = buildList { + runCatching { SearchCache.get().load().forEach { addAll(it.items) } } + runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } } + }.distinctBy { it.url } + fun onQueryChange(q: String) { _ui.value = _ui.value.copy(query = q) - // Reactive filter: scan every cached item (search-cache + subs - // feed-cache) as the user types. Cheap, runs in-memory, gives - // instant feedback before they hit Enter. Disabled when the - // user has turned off the cache feature. + // Reactive filter: scan the in-memory `pool` as the user types. + // Pool is a List walked once per keystroke — bounded + // (~1500 items typical), no disk I/O, no JSON decode. if (Settings.get().cacheEnabled.value && q.trim().length >= 2) { val matches = reactiveFilter(q.trim()) if (matches.isNotEmpty()) { @@ -64,6 +86,11 @@ class SearchViewModel : ViewModel() { loading = false, error = null, ) + } else if (_ui.value.fromCache) { + // User typed past what the cache can answer — drop the + // stale preview rather than leaving the prior query's + // results on screen pretending to match. + _ui.value = _ui.value.copy(results = emptyList(), fromCache = false) } } else if (q.isBlank()) { // Clear cached preview if the box is cleared. @@ -126,6 +153,10 @@ class SearchViewModel : ViewModel() { if (Settings.get().cacheEnabled.value) { withContext(Dispatchers.IO) { runCatching { SearchCache.get().record(q, items) } + // Refresh the in-memory pool with the new + // entries so subsequent reactive filters see + // them without waiting for a process restart. + pool.value = buildPool() } } } catch (t: Throwable) { @@ -140,24 +171,18 @@ class SearchViewModel : ViewModel() { } /** - * Walk the merged corpus of cached items (every saved search + - * every subs-feed channel cache) and return items whose title or - * uploader contains the query — case-insensitive, dedup by URL. - * Cheap: even with 30 cached queries * 20 items + 30 channels * 30 - * items it's < 1500 records, plenty fast for an in-memory filter. + * Walk the in-memory `pool` and return items whose title or uploader + * contains the query. Case-insensitive, capped at 60 results. + * No disk I/O on the hot path — `pool` is refreshed off-thread + * after each successful submit and at VM construction. */ private fun reactiveFilter(q: String): List { val needle = q.lowercase() - val pool = buildList { - SearchCache.get().load().forEach { addAll(it.items) } - FeedCache.get().load().values.forEach { addAll(it.items) } - } - return pool.asSequence() + return pool.value.asSequence() .filter { item -> item.title.lowercase().contains(needle) || item.uploader.lowercase().contains(needle) } - .distinctBy { it.url } .take(60) .toList() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 1f8339ca8..c5e834837 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -244,20 +244,30 @@ fun SettingsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton(onClick = { - val outcome = LogDump.capture(context) - outcome.onSuccess { intent -> - context.startActivity(android.content.Intent.createChooser(intent, "Share Straw logs")) - } - outcome.onFailure { t -> - Toast.makeText( - context, - "Log dump failed: ${t.message ?: t.javaClass.simpleName}", - Toast.LENGTH_LONG, - ).show() - } - }) { - Text("Export logs…") + var logDumping by remember { mutableStateOf(false) } + OutlinedButton( + enabled = !logDumping, + onClick = { + logDumping = true + scope.launch { + val outcome = LogDump.capture(context) + logDumping = false + outcome.onSuccess { intent -> + context.startActivity( + android.content.Intent.createChooser(intent, "Share Straw logs"), + ) + } + outcome.onFailure { t -> + Toast.makeText( + context, + "Log dump failed: ${t.message ?: t.javaClass.simpleName}", + Toast.LENGTH_LONG, + ).show() + } + } + }, + ) { + Text(if (logDumping) "Exporting…" else "Export logs…") } Spacer(modifier = Modifier.height(32.dp)) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt index ffb92672f..d0e1108ba 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt @@ -21,11 +21,12 @@ package com.sulkta.straw.net -import android.util.Log import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSpec import androidx.media3.datasource.HttpDataSource +import com.sulkta.straw.util.strawLogD +import com.sulkta.straw.util.strawLogW private const val TAG = "IosSafeDS" @@ -60,17 +61,19 @@ class IosSafeHttpDataSource( // come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs) // instead of `bytes=N-` (open, rejected with 403). val bounded = dataSpec.buildUpon().setLength(requestLen).build() - // Surface itag + mime from query so we can tell video vs audio apart in logs. + // Surface itag + mime from query so we can tell video vs audio apart in + // logs. SECURITY: do NOT include the full URL — pre-signed googlevideo + // URLs contain session-bound credentials (signature, sig, pot, expire, + // cpn) that would otherwise ride a `LogDump.capture` straight into the + // user's share-sheet target. Host + itag is enough to debug from. val u = dataSpec.uri val itag = u.getQueryParameter("itag") val mime = u.getQueryParameter("mime") - Log.i( - TAG, - "open: pos=${bounded.position} len=${bounded.length} " + - "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes) " + - "itag=$itag mime=$mime host=${u.host}", - ) - Log.i(TAG, "open url=${u.toString()}") + strawLogD(TAG) { + "open: host=${u.host} itag=$itag mime=$mime " + + "pos=${bounded.position} len=${bounded.length} " + + "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes)" + } originalSpec = dataSpec totalRead = 0 // inner.open() returns the BOUNDED chunk's length. Track it so we @@ -78,10 +81,10 @@ class IosSafeHttpDataSource( chunkRemaining = try { inner.open(bounded) } catch (t: Throwable) { - Log.w(TAG, "open failed: ${t.javaClass.simpleName}: ${t.message}") + strawLogW(TAG, t) { "open failed: ${t.javaClass.simpleName}" } throw t } - Log.i(TAG, "open: inner returned chunkRemaining=$chunkRemaining") + strawLogD(TAG) { "open: inner chunkRemaining=$chunkRemaining" } // Report the original (potentially unbounded) length to the caller — // ExoPlayer cares about the overall length, not our internal chunking. return if (dataSpec.length == C.LENGTH_UNSET.toLong()) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 817597596..6a5bb17d8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -25,7 +25,7 @@ data class RydVotes( object RydClient { private const val TAG = "StrawRyd" - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } /** Blocking — call from Dispatchers.IO. */ fun fetch(videoId: String): RydVotes? { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index 9979e58f8..77688c5ed 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -34,7 +34,7 @@ data class SbSegment( object SponsorBlockClient { private const val TAG = "StrawSb" - private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val json = Json { ignoreUnknownKeys = true } fun fetch( videoId: String, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index 9b4675aeb..d66eac8a6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -6,10 +6,14 @@ * Used from the Settings → "Export logs" action so users can attach a * log dump when reporting a problem. * - * NOTE: Android limits logcat-via-Runtime.exec to the calling app's - * own UID on API 30+, so this captures Straw's own log lines only - * (plus a sliver of system-wide messages tagged by our PID). No - * other app's logs are exposed. + * SECURITY: The dump is filtered before being written to disk — + * pre-signed googlevideo URLs, OAuth-style tokens, and anything + * matching the leak patterns below get scrubbed line-by-line. Without + * this, a user reporting a bug to Telegram would hand the chooser app + * their currently-playing session-bound streaming credentials. + * + * Android also limits logcat-via-Runtime.exec to the calling app's + * own UID on API 30+, so this captures Straw's own log lines only. */ package com.sulkta.straw.util @@ -24,53 +28,89 @@ import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext object LogDump { /** - * Pull recent logcat, write to a file in cacheDir, return a - * share-able Intent. Caller is responsible for `startActivity`. - * Returns null when the dump command itself fails — the caller - * shows a Toast with the error. + * Pull recent logcat, scrub sensitive substrings, write to a file + * in cacheDir, return a share-able Intent. Suspend so callers can + * stay off the main thread — `proc.waitFor()` plus a multi-MB + * `copyTo` is firmly an IO operation. */ - fun capture(context: Context): Result = runCatching { - val pid = Process.myPid() - val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") + suspend fun capture(context: Context): Result = withContext(Dispatchers.IO) { + runCatching { + val pid = Process.myPid() + val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") + val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp") - // -d dump-and-exit (no follow), -v threadtime is the - // most-greppable format, and the --pid filter restricts to - // our process so we don't accidentally exfiltrate sibling - // apps' chatter even when the system would allow it. - val cmd = arrayOf( - "logcat", - "-d", - "-v", - "threadtime", - "--pid=$pid", - ) - val proc = ProcessBuilder(*cmd) - .redirectErrorStream(true) - .start() - outFile.outputStream().use { out -> - proc.inputStream.copyTo(out) - } - val exit = proc.waitFor() - if (exit != 0) { - throw IOException("logcat exit=$exit") - } - if (outFile.length() == 0L) { - throw IOException("logcat produced 0 bytes (sandbox restriction?)") - } + // Sweep old dumps before writing the new one so cacheDir + // doesn't grow per export. + context.cacheDir.listFiles { _, name -> + name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp")) + }?.forEach { it.delete() } - // FileProvider authority — declared in AndroidManifest below. - val authority = "${context.packageName}.fileprovider" - val uri: Uri = FileProvider.getUriForFile(context, authority, outFile) - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + // -d dump-and-exit (no follow), -v threadtime is the + // most-greppable format, --pid filter restricts to our + // process so we don't exfiltrate sibling apps' chatter. + val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid") + val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start() + tmpFile.bufferedWriter().use { out -> + proc.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + out.write(scrubLine(line)) + out.newLine() + } + } + } + val exit = proc.waitFor() + if (exit != 0) { + tmpFile.delete() + throw IOException("logcat exit=$exit") + } + if (tmpFile.length() == 0L) { + tmpFile.delete() + throw IOException("logcat produced 0 bytes (sandbox restriction?)") + } + // Atomic-ish: only rename to final name on full success. + if (!tmpFile.renameTo(outFile)) { + tmpFile.delete() + throw IOException("rename failed") + } + + val authority = "${context.packageName}.fileprovider" + val uri: Uri = FileProvider.getUriForFile(context, authority, outFile) + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } } + + /** + * Pre-redact known credential-shaped substrings before they hit + * disk. Cheap line-level pass — adversarial-perfect would need a + * URL parser, but the regex approach catches every documented + * leak vector at zero allocation cost. + */ + internal fun scrubLine(line: String): String { + var s = line + // Pre-signed googlevideo URLs: keep host visible, drop path+query. + s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") + // Any remaining signed-param shapes that snuck through other URLs. + s = SIGNED_PARAM_RE.replace(s, "$1=") + return s + } + + private val GOOGLEVIDEO_URL_RE = Regex( + """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", + ) + private val SIGNED_PARAM_RE = Regex( + """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl)=([^&\s"']+)""", + RegexOption.IGNORE_CASE, + ) } diff --git a/strawApp/src/main/res/xml/data_extraction_rules.xml b/strawApp/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..163056f5c --- /dev/null +++ b/strawApp/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml index 92e012475..2cd9f5c6f 100644 --- a/strawApp/src/main/res/xml/file_paths.xml +++ b/strawApp/src/main/res/xml/file_paths.xml @@ -1,8 +1,14 @@ - + + + + + From 8f7ec129b31a9e3c037cc32fa43c27e707f7381a Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 13:31:02 -0700 Subject: [PATCH 22/87] vc=35 fix: missing fillMaxWidth import in PlayerScreen --- .../main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 47eccf046..9a110a69c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size From d1ee9379e070cd86a260e8c2861e22d5db6ada03 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 13:43:45 -0700 Subject: [PATCH 23/87] =?UTF-8?q?vc=3D36:=20audit-fix=20tail=20=E2=80=94?= =?UTF-8?q?=20atomic=20setPlayingFrom,=20cache=20wipe,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred items from the vc=35 audit-fix sprint. Smaller surface, real impact: HIGH-C6 — atomic setPlayingFrom claim StrawMediaController.setPlayingFrom previously did if (NowPlaying.current.value?.streamUrl == streamUrl) return setMediaItem(...); prepare(); play() NowPlaying.set(...) When the inline player and fullscreen Player effects fired in the same composition pass (an inline → fullscreen transition), both checks could see the stale NowPlaying value, both passed the guard, both ran setMediaItem + prepare + play. Result: an audible "did the video just restart?" stutter that was hard to reproduce. New: NowPlaying.claim(item) uses MutableStateFlow.compareAndSet in a CAS loop. Returns true ONLY for the caller that won the race; losing caller bails before touching the controller. The guard is now actually atomic, not a check-then-set. MED-Q11 — minibar surfaces playback errors Background button takes the user to Home with audio continuing in the foreground service. If that audio then fails (transient network drop on the resolved URL), neither the inline-player error listener nor PlayerScreen's exist anymore — only the minibar is observing. Added onPlayerError to MinibarOverlay's listener: Toast the errorCodeName + clear NowPlaying so the minibar hides itself rather than claiming a dead session is loaded. MED-Q15 — pre-compute recencyScore once mergeFromCache's compareByDescending invoked recencyScore() twice per pair (compareBy semantics), so ~1800 regex matches on a 900- item merge. Pair the score with the item once, sort the pair, take the items back. N matches. MED-C13 — Settings cache-wipe also clears in-memory VM SubscriptionFeedViewModel.clearInMemoryCache() exposed; Settings's Switch.onCheckedChange(false) now calls it alongside the disk wipe. Without this the feed kept rendering its in-memory mirror until process death. MED-C5 — drop StrawHome.formatDurationShort Near-duplicate of util.formatDuration. Used util's version + the existing `if (durationSeconds > 0)` guard at the call site already produces identical output (util returns "" on sec <= 0). MED-C19 — drop unused Surface import in StrawHome. NowPlaying gained one public method (claim). Everything else is internal-only churn. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +-- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 11 ++----- .../feature/feed/SubscriptionFeedViewModel.kt | 31 ++++++++++++++----- .../straw/feature/player/MinibarOverlay.kt | 14 +++++++++ .../sulkta/straw/feature/player/NowPlaying.kt | 25 +++++++++++++++ .../feature/player/StrawMediaController.kt | 15 ++++++--- .../straw/feature/settings/SettingsScreen.kt | 7 +++++ 7 files changed, 83 insertions(+), 24 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 4ba83af96..901e66c2e 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 35 -const val STRAW_VERSION_NAME = "0.1.0-AU" +const val STRAW_VERSION_CODE = 36 +const val STRAW_VERSION_NAME = "0.1.0-AV" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index ff2d26d34..2461a8182 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -48,7 +48,6 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -81,6 +80,7 @@ import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.OverlayDimColor +import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews import kotlinx.coroutines.launch @@ -524,7 +524,7 @@ private fun ThumbnailWithDuration( ) if (durationSeconds > 0) { Text( - text = formatDurationShort(durationSeconds), + text = formatDuration(durationSeconds), style = MaterialTheme.typography.labelSmall, color = androidx.compose.ui.graphics.Color.White, modifier = Modifier @@ -538,13 +538,6 @@ private fun ThumbnailWithDuration( } } -private fun formatDurationShort(totalSec: Long): String { - val h = totalSec / 3600 - val m = (totalSec % 3600) / 60 - val s = totalSec % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) -} - @Composable private fun SubChip( ch: ChannelRef, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index fbdff939b..e5a6408c4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -214,19 +214,34 @@ class SubscriptionFeedViewModel : ViewModel() { private fun mergeFromCache(channels: List): List { val subUrls = channels.map { it.url }.toSet() // Drop cache entries for unsubscribed channels so removed subs - // fall out of the feed immediately. + // fall out of the feed immediately. (Has a side effect on the + // ConcurrentHashMap — kept here for atomicity vs. a separate + // pass.) channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) } + // Newest-first across channels. Pre-compute recencyScore once + // per item — vc=35 audit MED-Q15: sortedWith's comparator was + // invoking the regex twice per pair, so ~1800 regex matches on + // a 900-item merge. Pairing the score before sort drops that + // to N matches. return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() } - // Newest-first across channels. Falls back to viewCount when - // we couldn't parse the relative date (older items + live - // streams come back without one). + .map { it to it.recencyScore() } .sortedWith( - compareByDescending { it.recencyScore() } - .thenByDescending { it.viewCount }, + compareByDescending> { it.second } + .thenByDescending { it.first.viewCount }, ) - // Generous cap. Anything past this is almost certainly noise - // for a feed view; pagination in the UI further slices this. .take(500) + .map { it.first } + } + + /** + * Clear in-memory cache. Called from Settings when the user flips + * off the local-cache toggle — disk wipe via FeedCacheStore.clear() + * was already there, but the VM kept its in-memory mirror so items + * stayed visible until process death. vc=35 audit MED-C13. + */ + fun clearInMemoryCache() { + channelCache.clear() + _ui.value = _ui.value.copy(items = emptyList(), lastFetchedAt = 0L) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt index b9dbdcda6..a66cff9a6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -69,11 +69,25 @@ fun MinibarOverlay( // Reflect the controller's play state in the play/pause icon. Listening // is the only reliable way; isPlaying snapshots stale between events. var isPlaying by remember { mutableStateOf(controller.isPlaying) } + val ctx = androidx.compose.ui.platform.LocalContext.current DisposableEffect(controller) { val listener = object : Player.Listener { override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing } + // vc=35 audit MED-Q11: if Background-button took the user + // to Home and the foreground audio fails, the only Player + // surface still listening is this minibar. Surface a Toast + // + clear NowPlaying so the minibar hides itself rather + // than claiming an already-dead session is "loaded". + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + android.widget.Toast.makeText( + ctx, + "playback error: ${error.errorCodeName}", + android.widget.Toast.LENGTH_LONG, + ).show() + NowPlaying.clear() + } } controller.addListener(listener) isPlaying = controller.isPlaying diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt index 75e00f29c..7c9cf6420 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -36,6 +36,31 @@ object NowPlaying { _current.value = item } + /** + * Atomically claim playback for `streamUrl`. Returns true if this + * call WON the claim (caller should now do setMediaItem + prepare + + * play). Returns false if someone else has already set the same + * streamUrl — typically because the inline-player effect and the + * fullscreen Player effect both fired in the same window during + * an inline→fullscreen transition. The losing caller does nothing; + * the winning caller's playback is already in flight. + * + * Uses MutableStateFlow.compareAndSet for the race-free transition. + * vc=35 audit HIGH-C6 — the previous "check NowPlaying then + * setPlayingFrom" sequence had a window where both checks could + * pass before either NowPlaying.set ran. + */ + fun claim(item: NowPlayingItem): Boolean { + while (true) { + val cur = _current.value + if (cur?.streamUrl == item.streamUrl) return false + if (_current.compareAndSet(cur, item)) return true + // Lost the CAS to a concurrent writer — retry against the + // fresh state. Bounded: at most a handful of competing + // callers in practice. + } + } + fun clear() { _current.value = null } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 498dc96a9..5c3013c5a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -82,11 +82,12 @@ fun MediaController.setPlayingFrom( resolved: ResolvedPlayback, startPositionMs: Long = 0L, ) { - val item = buildMediaItem(title, uploader, thumbnail, resolved) ?: return - setMediaItem(item, startPositionMs) - prepare() - playWhenReady = true - NowPlaying.set( + val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return + // Atomic claim BEFORE any controller mutation. If a concurrent + // caller already set this URL (inline player + fullscreen Player + // racing each other on the same transition), we bail before + // double-priming the player. vc=35 audit HIGH-C6. + val claimed = NowPlaying.claim( NowPlayingItem( streamUrl = streamUrl, title = title, @@ -95,6 +96,10 @@ fun MediaController.setPlayingFrom( segments = resolved.segments, ), ) + if (!claimed) return + setMediaItem(mediaItem, startPositionMs) + prepare() + playWhenReady = true } @UnstableApi diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index c5e834837..bc5e3583e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import android.widget.Toast +import androidx.lifecycle.viewmodel.compose.viewModel import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.MaxResolution @@ -50,6 +51,7 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.util.LogDump import kotlinx.coroutines.launch @@ -200,6 +202,7 @@ fun SettingsScreen() { style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, ) + val feedVm: SubscriptionFeedViewModel = viewModel() Switch( checked = cacheEnabled, onCheckedChange = { checked -> @@ -209,6 +212,10 @@ fun SettingsScreen() { // defeats the purpose of opting out. FeedCache.get().clear() SearchCache.get().clear() + // vc=35 audit MED-C13 — wipe the in-memory + // copy too, otherwise items stayed visible + // until process death. + feedVm.clearInMemoryCache() } }, ) From 567423336ce4666f49da6e947796b476964060fb Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 14:05:58 -0700 Subject: [PATCH 24/87] =?UTF-8?q?vc=3D37:=20round-2=20audit-fix=20sprint?= =?UTF-8?q?=20=E2=80=94=202=20CRIT=20+=2011=20HIGH=20+=204=20MED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three round-2 Opus audits ran on the vc=35+vc=36 surface. CVE returned no new CRITs (round-1 fixes hold) but found 5 new HIGH. Code-health found 2 CRIT — both my own vc=35 regressions. Function- correctness found 5 BROKEN that the round-1 sweep missed. CRIT (from code-health round 2) R1 Subs feed avatar-backfill self-cancel loop. Subscriptions.updateAvatar emits a new _subs reference; SubsPane's LaunchedEffect(subs) reacts → refreshIfStale → refresh() → inFlight.cancel(). With N channels needing backfill the parallel-12 batch degenerated into N sequential single-channel fetches that kept aborting each other. Gated refreshIfStale on inFlight.isActive != true. R2 HistoryStore.recordAllWatches O(N²) input. The vc=35 bulk-import path collapsed N SP writes into 1 (good) but used ArrayList.add(0, item) inside a loop walking up to 50k input rows before take(50). ~1.25B shifts worst case. Rewritten: walk newest-first, filter blanks + seen IDs, stop at MAX_WATCHES. O(N) bounded by output cap. HIGH (from CVE round 2) CVE-1 PlayerScreen + VideoDetailScreen rendered raw error.message into the UI — Media3 HttpDataSource exceptions include the full request URI with sig=/pot=. User screenshots a playback error to a chat → full session credentials in the picture. Both surfaces now scrub via LogDump.scrubLine before rendering. CVE-2 SubscriptionsStore.addAll counter race — updateAndGet's lambda re-runs on CAS retry; var-outside- lambda increment double-counted. Now derives `added` from next.size - cur.size delta. CVE-3 sweepStale ran deleteRecursively() on cacheDir (up to ~256MB) on the main thread inside Application.onCreate. Moved to appScope.launch(Dispatchers.IO). CVE-MED-2 Expanded LogDump.SIGNED_PARAM_RE alternation to include n / lsig / ei / key / sparams. CVE-MED-3 PlayerScreen + VideoDetailScreen error handlers now also NowPlaying.clear() so the minibar doesn't keep claiming a dead session is loaded. CVE-MED-4 SettingsImport validates imported subscription / playlist / history URLs against IMPORT_ALLOWED_HOSTS at import time. Hostile NewPipe export can no longer smuggle attacker-controlled URLs. HIGH (from code-health round 2) R3 Store constructors hit SP + JSON-decode on main thread at Application.onCreate. Small stores (Settings, History, Subscriptions, Playlists) stay eager — sub-millisecond cost. Heavy stores (FeedCache ~225 KB, SearchCache ~150 KB) now lazy-init: their `init()` just stashes applicationContext; the actual Store + disk decode is built on first `get()`, which happens from VM IO-dispatched coroutines. R4 SearchViewModel.pool race with init coroutine. Switched pool to a plain @Volatile var (no observers anyway — LOW- R14) and exposed rebuildPool() so the cache-toggle handler and a future explicit hook can refresh it. R5 SubsPane first-paint empty flash. Seeded SubscriptionFeedUiState(loading = true) in the VM's initial state — the init coroutine always runs. R6 Dropped dead uploaderAvatar field on StreamItem. Written three places, read zero. Saved bytes in every cache entry. R7 Split mergeFromCache into pruneCacheToSubs + mergeFromCache (no side effect in the reader). Callers do prune then merge. R8 Settings cache-disable wipe now runs on Dispatchers.IO (3 SP-edit calls were on the UI thread). HIGH (from function-correctness round 2) B1 refresh() empty-channels also wipes disk cache (was in-memory only — disk orphans accumulated). B2 Settings cache OFF→ON now triggers feedVm.refresh() + searchVm.rebuildPool() so the user doesn't have to navigate away and back to repopulate. B3 SearchViewModel.submit() cache lookup was still doing SearchCache.get().load() on main (CRIT-C1 was only partial). Now uses entries.value (StateFlow snapshot). B5 SearchCacheStore.record now atomic via MutableStateFlow + updateAndGet (was load()→write() with no atomicity, so concurrent records lost entries). Q9 History.recordWatch wrapped in withContext(Dispatchers.IO). Q11 Minibar onPlayerError also stops the controller + clears media items (was leaking dead controller state). MED R10 Added comments at the 4 pre-flight NowPlaying checks noting they're optimizations, claim() is the safety guard. Prevents a future refactor recreating the round-1 race after deleting "the guard." R11 Minibar Toast continues but now layered with the controller.stop() + clearMediaItems(). CVE-MED-1 NowPlaying.claim updates metadata fields when the same URL is re-claimed (was returning false unconditionally, pinning truncated search titles over fresh-from-detail titles). Q3 onQueryChange clears state.error so a failed-submit's banner doesn't haunt the next reactive preview. Deferred to vc=38 (intentional cost/benefit): CVE HIGH-2 (Rust strawcore::search query= info-log) — needs a separate strawcore-core edit + rebuild. Logged as a follow-up. CVE HIGH-3 (DownloadManager setVisibleInDownloadsUi deprecated on API 29+) — only the direct-streaming download replacement is a full fix; that's a multi-day refactor. Q5/Q8 (SettingsImport hostile zip silent abort UX) — cosmetic dialog-title fix. Q12 (loadedUrl assignment ordering) — pre-existing, deferred again. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 36 ++++++++++-- .../com/sulkta/straw/data/FeedCacheStore.kt | 28 ++++++--- .../com/sulkta/straw/data/HistoryStore.kt | 35 +++++++---- .../com/sulkta/straw/data/SearchCacheStore.kt | 54 ++++++++++++----- .../sulkta/straw/data/SubscriptionsStore.kt | 18 +++--- .../straw/feature/channel/ChannelViewModel.kt | 1 - .../feature/dataimport/SettingsImport.kt | 26 ++++++++- .../straw/feature/detail/VideoDetailScreen.kt | 21 ++++++- .../feature/detail/VideoDetailViewModel.kt | 29 ++++++---- .../feature/feed/SubscriptionFeedViewModel.kt | 48 +++++++++++---- .../straw/feature/player/MinibarOverlay.kt | 13 ++++- .../sulkta/straw/feature/player/NowPlaying.kt | 10 +++- .../straw/feature/player/PlayerScreen.kt | 14 ++++- .../straw/feature/search/SearchViewModel.kt | 58 ++++++++++++------- .../straw/feature/settings/SettingsScreen.kt | 33 ++++++++--- .../kotlin/com/sulkta/straw/util/LogDump.kt | 12 +++- 17 files changed, 328 insertions(+), 112 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 901e66c2e..4e4aea201 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 36 -const val STRAW_VERSION_NAME = "0.1.0-AV" +const val STRAW_VERSION_CODE = 37 +const val STRAW_VERSION_NAME = "0.1.0-AW" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index a6ae34c13..2d867add7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -13,8 +13,19 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch class StrawApp : Application() { + /** + * App-scoped coroutine scope for one-time startup work that + * shouldn't tie up Application.onCreate. SupervisorJob so a failure + * in one launch doesn't cascade. + */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onCreate() { super.onCreate() // Path C-7: route Rust `log::*` calls into Android logcat under tag @@ -22,16 +33,29 @@ class StrawApp : Application() { // strawcore is silently dropped, making playback regressions invisible // from `adb logcat`. uniffi.strawcore.initLogging() - History.init(this) + // Small + universally-accessed stores: synchronous init. + // Settings is a handful of SP keys (read on first compose for + // themeMode), History caps at 50 watches + 20 searches, + // Subscriptions is a single channel list — sub-millisecond + // cost on cold cache. Settings.init(this) + History.init(this) Subscriptions.init(this) Playlists.init(this) + // vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache + // (~150 KB) JSON-decode at construction. Stash the + // applicationContext eagerly (cheap) so `get()` is callable + // anywhere; the actual store construction (and the disk + // decode that goes with it) is lazy. ViewModels accessing + // these on IO trigger the construction there — never on the + // main thread. FeedCache.init(this) SearchCache.init(this) - // Sweep any newpipe-import-* work-dirs left in cacheDir by a - // previous import that was killed mid-extraction. CRIT from - // the vc=34 security audit — the user's full NewPipe DB would - // otherwise live in cacheDir until the next deleteRecursively. - SettingsImport.sweepStale(this) + // vc=36 audit CVE HIGH-5: sweepStale's deleteRecursively() + // can walk ~256 MB if a previous import was LMK-killed + // mid-extraction. Strictly off the main thread. + appScope.launch { + SettingsImport.sweepStale(this@StrawApp) + } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index 0464d25a3..deb46f2f6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -56,16 +56,30 @@ class FeedCacheStore(context: Context) { } object FeedCache { + @Volatile private var appContext: Context? = null @Volatile private var instance: FeedCacheStore? = null + /** + * Lazy init: stash the applicationContext only. The actual Store + * (and the ~225 KB JSON decode that happens at construction) is + * deferred until the first `get()` call. Lets Application.onCreate + * return quickly while every caller still gets a valid Store — + * vc=36 audit HIGH-R3. Callers should access from a coroutine + * (IO dispatcher) where the lazy construction cost is acceptable. + */ fun init(context: Context) { - if (instance == null) { - synchronized(this) { - if (instance == null) instance = FeedCacheStore(context.applicationContext) - } - } + appContext = context.applicationContext } - fun get(): FeedCacheStore = instance - ?: error("FeedCacheStore not initialized — call FeedCache.init(context)") + fun get(): FeedCacheStore { + instance?.let { return it } + synchronized(this) { + instance?.let { return it } + val ctx = appContext + ?: error("FeedCacheStore not initialized — call FeedCache.init(context)") + val built = FeedCacheStore(ctx) + instance = built + return built + } + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 7ecc397d7..8e944d7c6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -58,24 +58,37 @@ class HistoryStore(context: Context) { /** * Bulk import. Callers (currently SettingsImport) feed - * oldest→newest so the most-recent entries end up at the front - * of the capped list. Single SP write — vc=34 audit flagged the + * oldest→newest. Single SP write — vc=34 audit flagged the * per-row recordWatch in importHistory as a write-storm vector. + * + * O(N) on input size, not O(N²). The vc=35 first cut had an + * `add(0, item)` inside a loop walking up to MAX_HISTORY_IMPORT + * (~50k) entries — ArrayList shift over `merged.size` each step, + * a billion+ shifts in the worst case for a final `take(50)` that + * discards 99.9% of the work. Round-2 audit CRIT-R2. + * + * New shape: walk input newest-first (reversed; SettingsImport + * fed oldest-first), filter blanks + already-seen videoIds, take + * up to MAX_WATCHES, prepend to current. Done in one pass with + * the capped output never exceeding MAX_WATCHES. */ fun recordAllWatches(items: List) { if (items.isEmpty()) return val next = _watches.updateAndGet { current -> - val seen = current.map { it.videoId }.toMutableSet() - val merged = current.toMutableList() - for (item in items) { + val seen = HashSet(current.size + items.size) + current.forEach { seen.add(it.videoId) } + val capacity = (MAX_WATCHES - current.size).coerceAtLeast(0) + if (capacity == 0) return@updateAndGet current + val fresh = ArrayList(capacity) + // Walk newest-first; stop as soon as we have capacity. + val it = items.listIterator(items.size) + while (it.hasPrevious() && fresh.size < capacity) { + val item = it.previous() if (item.videoId.isBlank()) continue - if (item.videoId in seen) { - merged.removeAll { it.videoId == item.videoId } - } - seen.add(item.videoId) - merged.add(0, item) + if (!seen.add(item.videoId)) continue + fresh.add(item) } - merged.take(MAX_WATCHES) + (fresh + current).take(MAX_WATCHES) } sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index 10f5a961d..ff917b7e4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -11,6 +11,11 @@ * Sized for SharedPreferences: 30 queries * 20 items each * ~250 bytes * = ~150 KB worst case. * + * Backed by a MutableStateFlow loaded once at construction — + * record()/load() are atomic against concurrent calls. vc=36 audit + * B5: the prior load()→edit()→write() pattern would clobber a + * concurrent record() with whichever happened to persist last. + * * Skips entirely when Settings.cacheEnabled is false — caller checks * the flag before reading/writing. */ @@ -20,6 +25,10 @@ package com.sulkta.straw.data import android.content.Context import android.content.SharedPreferences import com.sulkta.straw.feature.search.StreamItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -39,43 +48,60 @@ class SearchCacheStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } - fun load(): List = runCatching { - val s = sp.getString(KEY, null) ?: return emptyList() - json.decodeFromString>(s) - }.getOrDefault(emptyList()) + private val _entries = MutableStateFlow(loadFromDisk()) + val entries: StateFlow> = _entries.asStateFlow() + + /** Snapshot of the cache. Used by the reactive search filter. */ + fun load(): List = _entries.value /** * Record a freshly-fetched query result. Idempotent: a re-run of * the same query overwrites the prior entry rather than duplicating. * Oldest entries fall off when MAX_QUERIES is exceeded. + * + * Atomic via updateAndGet — concurrent records don't lose entries. */ fun record(query: String, items: List) { val q = query.trim() if (q.isEmpty() || items.isEmpty()) return val capped = items.take(MAX_ITEMS_PER_QUERY) val now = System.currentTimeMillis() - val current = load() - val without = current.filterNot { it.query.equals(q, ignoreCase = true) } - val next = (listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES) + val next = _entries.updateAndGet { current -> + val without = current.filterNot { it.query.equals(q, ignoreCase = true) } + (listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES) + } sp.edit().putString(KEY, json.encodeToString(next)).apply() } fun clear() { + _entries.value = emptyList() sp.edit().remove(KEY).apply() } + + private fun loadFromDisk(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(s) + }.getOrDefault(emptyList()) } object SearchCache { + @Volatile private var appContext: Context? = null @Volatile private var instance: SearchCacheStore? = null + /** Lazy init — see FeedCache.init for the rationale. */ fun init(context: Context) { - if (instance == null) { - synchronized(this) { - if (instance == null) instance = SearchCacheStore(context.applicationContext) - } - } + appContext = context.applicationContext } - fun get(): SearchCacheStore = instance - ?: error("SearchCacheStore not initialized — call SearchCache.init(context)") + fun get(): SearchCacheStore { + instance?.let { return it } + synchronized(this) { + instance?.let { return it } + val ctx = appContext + ?: error("SearchCacheStore not initialized — call SearchCache.init(context)") + val built = SearchCacheStore(ctx) + instance = built + return built + } + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index e4d897927..9675b005e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -73,20 +73,22 @@ class SubscriptionsStore(context: Context) { * caller can report an "added X" stat. */ fun addAll(refs: List): Int { - var added = 0 - val next = _subs.updateAndGet { cur -> - val byUrl = cur.associateBy { it.url }.toMutableMap() + // Derive `added` from the size delta INSTEAD of incrementing a + // var inside updateAndGet's lambda — that lambda can re-run + // under CAS contention (a concurrent toggle from the channel + // screen during a 500-row import), and a var-outside-lambda + // accumulates across retries. vc=36 audit CVE HIGH-4. + val cur = _subs.value + val next = _subs.updateAndGet { state -> + val byUrl = state.associateBy { it.url }.toMutableMap() for (r in refs) { if (r.url.isBlank()) continue - if (r.url !in byUrl) { - byUrl[r.url] = r - added++ - } + if (r.url !in byUrl) byUrl[r.url] = r } byUrl.values.toList() } persist(next) - return added + return next.size - cur.size } fun clear() { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 027d0bfa9..82a3d4245 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -42,7 +42,6 @@ class ChannelViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader, uploaderUrl = v.uploaderUrl, - uploaderAvatar = ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index f2bab6d72..b35185009 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -102,6 +102,23 @@ object SettingsImport { // YouTube only — Straw doesn't extract from other services. private const val YT_SERVICE_ID = 0 + // Mirror of StrawActivity.YT_HOSTS — kept inline rather than + // imported because the activity holds the canonical copy and + // SettingsImport is the only other consumer. + // vc=36 audit CVE MED-4 — validate imported URLs at import time + // so a hostile NewPipe export can't smuggle attacker-controlled + // URLs into PlaylistStore / HistoryStore. + private val IMPORT_ALLOWED_HOSTS = setOf( + "youtube.com", "www.youtube.com", "m.youtube.com", + "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com", + "youtu.be", + ) + + private fun isAllowedYtUrl(url: String): Boolean { + val host = runCatching { java.net.URI(url).host?.lowercase() }.getOrNull() ?: return false + return host in IMPORT_ALLOWED_HOSTS + } + suspend fun run(context: Context, zipUri: Uri): Result = withContext(Dispatchers.IO) { runCatching { runInner(context, zipUri) } @@ -275,6 +292,10 @@ object SettingsImport { continue } val url = c.getString(0) ?: continue + if (!isAllowedYtUrl(url)) { + skipped++ + continue + } val name = c.getString(1) ?: continue val avatar = c.getString(2) staged += ChannelRef(url = url, name = name, avatar = avatar) @@ -314,8 +335,10 @@ object SettingsImport { ).use { c -> while (c.moveToNext()) { if (c.getInt(4) != YT_SERVICE_ID) continue + val streamUrl = c.getString(0) ?: continue + if (!isAllowedYtUrl(streamUrl)) continue items += PlaylistItem( - streamUrl = c.getString(0) ?: continue, + streamUrl = streamUrl, title = c.getString(1) ?: "(no title)", thumbnail = c.getString(2), uploader = c.getString(3) ?: "", @@ -391,6 +414,7 @@ object SettingsImport { while (c.moveToNext()) { if (c.getInt(5) != YT_SERVICE_ID) continue val url = c.getString(0) ?: continue + if (!isAllowedYtUrl(url)) continue val title = c.getString(1) ?: continue val uploader = c.getString(2) ?: "" val thumb = c.getString(3) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 8e6304966..d28b26527 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -99,6 +99,7 @@ import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.NowPlaying import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -346,6 +347,11 @@ fun VideoDetailScreen( // Make sure the controller is playing this video // before backing out — otherwise dropping to the // minibar would dismiss into an empty slot. + // Optimization: skip the MediaItem build if + // the controller is already on this URL. + // claim() in setPlayingFrom is the + // authoritative race-free guard — this + // check is just to avoid the work. if (NowPlaying.current.value?.streamUrl != streamUrl) { val r = state.resolved if (r == null) { @@ -397,6 +403,11 @@ fun VideoDetailScreen( Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() return@OutlinedButton } + // Optimization: skip the MediaItem build if + // the controller is already on this URL. + // claim() in setPlayingFrom is the + // authoritative race-free guard — this + // check is just to avoid the work. if (NowPlaying.current.value?.streamUrl != streamUrl) { c.setPlayingFrom( streamUrl = streamUrl, @@ -714,6 +725,7 @@ private fun InlinePlayer( LaunchedEffect(controller, resolved, streamUrl) { val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect + // Optimization, not safety. claim() guards the race. if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect c.setPlayingFrom( streamUrl = streamUrl, @@ -729,7 +741,14 @@ private fun InlinePlayer( val c = controller val listener = object : Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}" + // Scrub the message — Media3's HttpDataSource exceptions + // include the full signed URL in .message. vc=36 audit + // CVE HIGH-1. + val raw = error.message ?: "(no message)" + playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}" + // Clear NowPlaying so the minibar drops the dead + // session. vc=36 audit MED-3. + NowPlaying.clear() } } c?.addListener(listener) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 19e2b2fb5..61e8744b8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -101,17 +101,23 @@ class VideoDetailViewModel : ViewModel() { val title = info.title.ifBlank { "(no title)" } val uploader = info.uploader - runCatching { - History.get().recordWatch( - WatchHistoryItem( - url = streamUrl, - videoId = videoId, - title = title, - uploader = uploader, - thumbnail = thumb, - watchedAt = 0L, - ), - ) + // Move SP write off the main coroutine — recordWatch + // JSON-encodes the watch list (up to 50 entries) + + // sp.edit().apply(). Small but synchronous; vc=36 + // audit Q9. + withContext(Dispatchers.IO) { + runCatching { + History.get().recordWatch( + WatchHistoryItem( + url = streamUrl, + videoId = videoId, + title = title, + uploader = uploader, + thumbnail = thumb, + watchedAt = 0L, + ), + ) + } } val ryd = withContext(Dispatchers.IO) { @@ -152,7 +158,6 @@ class VideoDetailViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { uploader }, uploaderUrl = v.uploaderUrl ?: uploaderUrl, - uploaderAvatar = ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index e5a6408c4..2dffb22e9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -49,7 +49,11 @@ data class SubscriptionFeedUiState( ) class SubscriptionFeedViewModel : ViewModel() { - private val _ui = MutableStateFlow(SubscriptionFeedUiState()) + // Seed loading=true: the init block always either hydrates from + // disk or fires a refresh, so the user should see the spinner + // (or cached content under it) rather than a one-frame flash of + // empty. vc=36 audit HIGH-R5. + private val _ui = MutableStateFlow(SubscriptionFeedUiState(loading = true)) val ui: StateFlow = _ui.asStateFlow() /** @@ -77,6 +81,7 @@ class SubscriptionFeedViewModel : ViewModel() { channelCache.putAll(saved) val channels = Subscriptions.get().subs.value if (channels.isNotEmpty()) { + pruneCacheToSubs(channels) _ui.value = _ui.value.copy( items = mergeFromCache(channels), lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L, @@ -112,6 +117,14 @@ class SubscriptionFeedViewModel : ViewModel() { private var inFlight: Job? = null fun refreshIfStale() { + // Skip if a refresh is already in flight. vc=36 audit CRIT-R1: + // SubsPane's LaunchedEffect(subs) re-fires every time + // Subscriptions.updateAvatar emits a fresh list reference (which + // fetchChannelInto does opportunistically per channel). Without + // this gate, each per-channel avatar backfill cancels the + // parallel-12 batch and turns the refresh into N sequential + // single-channel fetches. + if (inFlight?.isActive == true) return val now = System.currentTimeMillis() val anyStale = Subscriptions.get().subs.value.any { ch -> val entry = channelCache[ch.url] @@ -125,6 +138,14 @@ class SubscriptionFeedViewModel : ViewModel() { if (channels.isEmpty()) { _ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) } channelCache.clear() + // Wipe disk too. vc=36 audit B1: previously the disk + // cache kept stale entries indefinitely after the user + // unsubscribed from everything. mergeFromCache eventually + // prunes them on the next merge, but they sat as orphans + // through cold starts in the meantime. + viewModelScope.launch(Dispatchers.IO) { + runCatching { FeedCache.get().clear() } + } return } inFlight?.cancel() @@ -142,6 +163,7 @@ class SubscriptionFeedViewModel : ViewModel() { .map { ch -> async { gate.withPermit { fetchChannelInto(ch) } } } .awaitAll() } + pruneCacheToSubs(channels) _ui.update { SubscriptionFeedUiState( loading = false, @@ -189,7 +211,6 @@ class SubscriptionFeedViewModel : ViewModel() { title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { ch.name }, uploaderUrl = v.uploaderUrl ?: ch.url, - uploaderAvatar = freshAvatar ?: ch.avatar, thumbnail = v.thumbnail, durationSeconds = v.durationSeconds, viewCount = v.viewCount, @@ -211,18 +232,21 @@ class SubscriptionFeedViewModel : ViewModel() { } } - private fun mergeFromCache(channels: List): List { + private fun pruneCacheToSubs(channels: List) { val subUrls = channels.map { it.url }.toSet() - // Drop cache entries for unsubscribed channels so removed subs - // fall out of the feed immediately. (Has a side effect on the - // ConcurrentHashMap — kept here for atomicity vs. a separate - // pass.) channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) } - // Newest-first across channels. Pre-compute recencyScore once - // per item — vc=35 audit MED-Q15: sortedWith's comparator was - // invoking the regex twice per pair, so ~1800 regex matches on - // a 900-item merge. Pairing the score before sort drops that - // to N matches. + } + + private fun mergeFromCache(channels: List): List { + // Pure read. Caller is responsible for calling pruneCacheToSubs + // beforehand when channel-set changes matter — split here + // because the prior version's "merge" name hid a side-effecting + // prune that violated single-responsibility (vc=36 audit + // HIGH-R7). + // + // Pre-compute recencyScore once per item — vc=35 audit + // MED-Q15: sortedWith's comparator was invoking the regex + // twice per pair, so ~1800 regex matches on a 900-item merge. return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() } .map { it to it.recencyScore() } .sortedWith( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt index a66cff9a6..596d609aa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -77,15 +77,22 @@ fun MinibarOverlay( } // vc=35 audit MED-Q11: if Background-button took the user // to Home and the foreground audio fails, the only Player - // surface still listening is this minibar. Surface a Toast - // + clear NowPlaying so the minibar hides itself rather - // than claiming an already-dead session is "loaded". + // surface still listening is this minibar. + // vc=36 audit MED-3 + Q11: also stop the controller so a + // future tap doesn't seek into the dead state, AND clear + // NowPlaying so the minibar hides itself. (PlayerScreen + // and VideoDetailScreen's listeners also clear NowPlaying + // now, so this is the fallback when neither is alive.) override fun onPlayerError(error: androidx.media3.common.PlaybackException) { android.widget.Toast.makeText( ctx, "playback error: ${error.errorCodeName}", android.widget.Toast.LENGTH_LONG, ).show() + runCatching { + controller.stop() + controller.clearMediaItems() + } NowPlaying.clear() } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt index 7c9cf6420..247ed09ef 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -53,7 +53,15 @@ object NowPlaying { fun claim(item: NowPlayingItem): Boolean { while (true) { val cur = _current.value - if (cur?.streamUrl == item.streamUrl) return false + if (cur?.streamUrl == item.streamUrl) { + // Same URL — caller doesn't need to re-prepare the + // player, but if it brought richer metadata (full + // title vs the search-result truncation, fresh + // thumbnail, updated SponsorBlock segments) refresh + // those fields. vc=36 round-2 CVE MED-1. + if (cur != item) _current.compareAndSet(cur, item) + return false + } if (_current.compareAndSet(cur, item)) return true // Lost the CAS to a concurrent writer — retry against the // fresh state. Bounded: at most a handful of competing diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 9a110a69c..155f471c2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -75,6 +75,7 @@ import androidx.media3.ui.PlayerView import com.sulkta.straw.OverlayChromeColor import com.sulkta.straw.feature.detail.VideoDetailViewModel import com.sulkta.straw.net.SbSegment +import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.strawLogI import kotlinx.coroutines.delay @@ -108,6 +109,7 @@ fun PlayerScreen( val r = resolved ?: return@LaunchedEffect val uploader = detail?.uploader.orEmpty() val thumbnail = detail?.thumbnail + // Optimization, not safety. claim() guards the race. if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect c.setPlayingFrom( streamUrl = streamUrl, @@ -124,7 +126,17 @@ fun PlayerScreen( val c = controller val listener = object : Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}" + // Scrub the message before rendering. Media3's + // HttpDataSource exceptions embed the full request URI + // (with signature= / pot= / cpn=) in the .message + // string — visible in the on-screen error banner and + // a screenshot away from being shared. vc=36 audit + // CVE HIGH-1. + val raw = error.message ?: "(no message)" + playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}" + // Also clear NowPlaying so the minibar doesn't keep + // claiming a dead session is loaded. vc=36 audit MED-3. + NowPlaying.clear() } } c?.addListener(listener) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 6cb952a1f..4b938edd3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -37,7 +37,6 @@ data class StreamItem( val title: String, val uploader: String, val uploaderUrl: String?, - val uploaderAvatar: String? = null, val thumbnail: String?, val durationSeconds: Long, val viewCount: Long, @@ -51,18 +50,36 @@ class SearchViewModel : ViewModel() { /** * In-memory snapshot of the disk corpus (saved search results + - * subs feed cache) for reactive filtering. Hydrated on Dispatchers.IO - * once at VM construction and refreshed after a successful submit. - * vc=34 audit CRIT — the previous implementation hit - * SharedPreferences + JSON-decoded ~225 KB on every keystroke, - * blocking the main thread. + * subs feed cache) for reactive filtering. Hydrated on + * Dispatchers.IO once at VM construction and refreshed after a + * successful submit. vc=34 audit CRIT-C1 — the previous + * implementation hit SharedPreferences + JSON-decoded ~225 KB on + * every keystroke, blocking the main thread. + * + * Plain @Volatile not StateFlow because nothing observes it + * (vc=36 audit LOW-R14 — the StateFlow synchronization buys + * nothing here). */ - private val pool = MutableStateFlow>(emptyList()) + @Volatile + private var pool: List = emptyList() init { + rebuildPool() + } + + /** + * Re-read both caches off the main thread and replace the pool + * snapshot. Called at construction and from Settings when the + * cache toggle flips ON (so a re-enable picks up freshly-seeded + * entries from a subsequent submit/refresh without waiting for + * process death). vc=36 audit B2/Q10. + */ + fun rebuildPool() { viewModelScope.launch { - if (Settings.get().cacheEnabled.value) { - pool.value = withContext(Dispatchers.IO) { buildPool() } + pool = if (Settings.get().cacheEnabled.value) { + withContext(Dispatchers.IO) { buildPool() } + } else { + emptyList() } } } @@ -73,10 +90,11 @@ class SearchViewModel : ViewModel() { }.distinctBy { it.url } fun onQueryChange(q: String) { - _ui.value = _ui.value.copy(query = q) - // Reactive filter: scan the in-memory `pool` as the user types. - // Pool is a List walked once per keystroke — bounded - // (~1500 items typical), no disk I/O, no JSON decode. + // Clear any prior error state when the user resumes typing — + // a failed submit's banner used to persist into the next + // reactive preview, looking like the new query had failed. + // vc=36 audit Q3. + _ui.value = _ui.value.copy(query = q, error = null) if (Settings.get().cacheEnabled.value && q.trim().length >= 2) { val matches = reactiveFilter(q.trim()) if (matches.isNotEmpty()) { @@ -84,16 +102,11 @@ class SearchViewModel : ViewModel() { results = matches, fromCache = true, loading = false, - error = null, ) } else if (_ui.value.fromCache) { - // User typed past what the cache can answer — drop the - // stale preview rather than leaving the prior query's - // results on screen pretending to match. _ui.value = _ui.value.copy(results = emptyList(), fromCache = false) } } else if (q.isBlank()) { - // Clear cached preview if the box is cleared. _ui.value = _ui.value.copy(results = emptyList(), fromCache = false) } } @@ -102,10 +115,13 @@ class SearchViewModel : ViewModel() { val q = _ui.value.query.trim() if (q.isEmpty()) return - // Cache hit on submit: show immediately, kick off a refresh - // behind it so the user gets fresh items shortly after. + // Cache hit on submit: show immediately, kick off refresh + // behind it. vc=36 audit B3 — the previous shape called + // `SearchCache.get().load()` on the main thread, doing the + // exact ~150 KB JSON decode the reactive-filter fix was + // supposed to eliminate. Now uses the StateFlow snapshot. val cached = if (Settings.get().cacheEnabled.value) { - SearchCache.get().load() + SearchCache.get().entries.value .firstOrNull { it.query.equals(q, ignoreCase = true) } ?.items } else null diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index bc5e3583e..332e5b2f3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -52,7 +52,10 @@ import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel +import com.sulkta.straw.feature.search.SearchViewModel import com.sulkta.straw.util.LogDump +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.launch @Composable @@ -203,19 +206,31 @@ fun SettingsScreen() { fontWeight = FontWeight.SemiBold, ) val feedVm: SubscriptionFeedViewModel = viewModel() + val searchVm: SearchViewModel = viewModel() Switch( checked = cacheEnabled, onCheckedChange = { checked -> store.setCacheEnabled(checked) - if (!checked) { - // Wipe on disable — leaving stale bytes around - // defeats the purpose of opting out. - FeedCache.get().clear() - SearchCache.get().clear() - // vc=35 audit MED-C13 — wipe the in-memory - // copy too, otherwise items stayed visible - // until process death. - feedVm.clearInMemoryCache() + scope.launch { + if (!checked) { + withContext(Dispatchers.IO) { + runCatching { FeedCache.get().clear() } + runCatching { SearchCache.get().clear() } + } + feedVm.clearInMemoryCache() + // Drop the in-memory reactive-search pool + // too — without this, typing into Search + // still surfaces hits from the just-wiped + // disk cache. + searchVm.rebuildPool() + } else { + // Cache re-enabled: trigger a real refresh + // so the feed repopulates without waiting + // for the user to navigate away and back. + // vc=36 audit B2. + feedVm.refresh() + searchVm.rebuildPool() + } } }, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index d66eac8a6..d5279a144 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -96,12 +96,20 @@ object LogDump { * disk. Cheap line-level pass — adversarial-perfect would need a * URL parser, but the regex approach catches every documented * leak vector at zero allocation cost. + * + * Public so error-handler call sites (PlayerScreen / VideoDetail + * `playbackError`) can scrub Media3's `PlaybackException.message` + * before rendering it to the user — that string includes the full + * request URI for HttpDataSource exceptions, which would otherwise + * be a leak via screenshot. vc=36 audit CVE HIGH-1. */ - internal fun scrubLine(line: String): String { + fun scrubLine(line: String): String { var s = line // Pre-signed googlevideo URLs: keep host visible, drop path+query. s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") // Any remaining signed-param shapes that snuck through other URLs. + // Expanded set vc=36 audit CVE MED-2: + n (JS-deobfuscated n-sig), + // lsig (link signature), ei (encrypted event-id), key, sparams. s = SIGNED_PARAM_RE.replace(s, "$1=") return s } @@ -110,7 +118,7 @@ object LogDump { """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) private val SIGNED_PARAM_RE = Regex( - """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl)=([^&\s"']+)""", + """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl|n|lsig|ei|key|sparams)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) } From ec9d2f37afa749660f13d2ccb829151f74ae794c Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 14:09:11 -0700 Subject: [PATCH 25/87] vc=37 fix: pool changed from StateFlow to plain var; drop .value refs --- .../kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 4b938edd3..082d61fdb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -172,7 +172,7 @@ class SearchViewModel : ViewModel() { // Refresh the in-memory pool with the new // entries so subsequent reactive filters see // them without waiting for a process restart. - pool.value = buildPool() + pool = buildPool() } } } catch (t: Throwable) { @@ -194,7 +194,7 @@ class SearchViewModel : ViewModel() { */ private fun reactiveFilter(q: String): List { val needle = q.lowercase() - return pool.value.asSequence() + return pool.asSequence() .filter { item -> item.title.lowercase().contains(needle) || item.uploader.lowercase().contains(needle) From 780bb6152c4e7c959d694c12775d6df3378bcd84 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 14:11:00 -0700 Subject: [PATCH 26/87] vc=37 (rust): scrub PII from strawcore info-logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CVE round-2 HIGH-2: android_logger is configured at info-level in release builds, so log::info!('strawcore::search query={}', query) emits the user's actual search query to logcat. LogDump.scrubLine's regex only catches googlevideo URLs + signed params — bare search text rides through into a Settings → Export Logs share-sheet attachment intact. Same for channel_info / stream_info URLs. Replaced the value-bearing logs with shape-only (query_len / input_len). The shape is enough to debug 'why did the search return empty?' without the privacy hit. --- rust/strawcore/src/channel.rs | 2 +- rust/strawcore/src/search.rs | 7 ++++++- rust/strawcore/src/stream.rs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs index 67d48b8a9..3434905e5 100644 --- a/rust/strawcore/src/channel.rs +++ b/rust/strawcore/src/channel.rs @@ -23,7 +23,7 @@ pub struct ChannelInfo { #[uniffi::export(async_runtime = "tokio")] pub async fn channel_info(input: String) -> Result { - log::info!("strawcore::channel_info input={}", input); + log::info!("strawcore::channel_info input_len={}", input.len()); let identifier = resolve_channel_identifier(&input)?; let core = tokio::task::spawn_blocking(move || core_channel_info(identifier)) .await diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index b85b7ce7f..0a693ff61 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -54,7 +54,12 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { #[uniffi::export(async_runtime = "tokio")] pub async fn search(query: String) -> Result, StrawcoreError> { - log::info!("strawcore::search query={}", query); + // Don't log the query itself — searches are PII (sometimes + // names, sometimes embarrassing) and android_logger emits at + // info-level in release builds, which means they'd ride the + // Settings → Export Logs path straight into a user's chat. Log + // shape, not content. vc=36 audit CVE HIGH-2. + log::info!("strawcore::search query_len={}", query.len()); let result = tokio::task::spawn_blocking(move || { search_extractor::search(&query, SearchFilter::Videos) }) diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index 7a4ce6839..73da83e58 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -57,7 +57,7 @@ pub struct AudioStreamItem { #[uniffi::export(async_runtime = "tokio")] pub async fn stream_info(input: String) -> Result { - log::info!("strawcore::stream_info input={}", input); + log::info!("strawcore::stream_info input_len={}", input.len()); let video_id = resolve_video_id(&input)?; let video_id_for_call = video_id.clone(); let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call)) From cbdba302ce5a5efe95b9006cbaf06e91ec2deeb7 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 14:29:32 -0700 Subject: [PATCH 27/87] =?UTF-8?q?vc=3D38:=20round-3=20audit-fix=20sprint?= =?UTF-8?q?=20=E2=80=94=209=20HIGH=20+=207=20MED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three round-3 Opus audits ran on vc=37. NO new CRITs (round-2 work held) but real new HIGHs — several were vc=37 own-goals. HIGH R3-1 recordAllWatches dropped import on capacity=0. Old: when watches store hit MAX_WATCHES (50), capacity=0, the whole import was discarded silently. New: build fresh import list capped at MAX_WATCHES, then combine + take(MAX_WATCHES) so imports always land (truncating oldest current entries). Also: skip SP write when next === before (no-op import on already-saturated store no longer thrashes disk). New recordAllSearches with same shape — round-3 CVE MED-6: importHistory was per-row recordSearch. R3-2 / CVE-2 SubscriptionsStore.addAll counter race. The vc=36 size-delta fix snapshot `cur = _subs.value` BEFORE updateAndGet, so a concurrent toggle inflated `added`. New: AtomicInteger reset at the start of each lambda re-run, counted by checking each ref against the pre-image inside the CAS. Exactly the additions THIS call made. R3-3 refresh() empty-channels didn't cancel inFlight. Cancel moved to the top of refresh() unconditionally so a refresh on the prior sub set is killed before the empty branch clears + wipes disk. clearInMemoryCache also cancels inFlight — without it, a cache-disable flip during a refresh could see fetchChannelInto re-populate the just-cleared map. R3-4 Non-atomic `_ui.value = it.copy(...)` at init hydrate path and clearInMemoryCache. Replaced with `_ui.update {}` for atomicity vs concurrent refresh writes. init's lastFetchedAt write now uses maxOf so it never regresses past a fresh refresh value. CVE-1 state.error rendered raw UniFFI/Rust error strings to UI — NetworkError::Recaptcha { url } embeds full signed googlevideo URL. User screenshots a "reCAPTCHA at " banner → leak. All four VMs (Channel/Detail/Feed/Search) now scrub via LogDump.scrubLine before storing. CVE-3 pruneCacheToSubs in init can clobber concurrent fetchChannelInto writes. init's putAll → putIfAbsent so a fresh entry from a parallel refresh isn't overwritten with disk-stale data. CVE-4 SIGNED_PARAM_RE over-redacted short tokens (`\bn=` matched `n=42` counters from any wrapped lib). Split into SIGNED_PARAM_LONG_RE (signature/sparams/lsig/cpn/expire/ pot/sig/key — match anywhere) and SIGNED_PARAM_SHORT_RE (n/mn/ms/mo/pl/ip/ei — require `[?&]` immediately before). Func-HIGH-1 refresh() swallowed CancellationException as a user-visible error. Spam-tapping Refresh produced a "refresh failed: StandaloneCoroutineCancelled" banner. Re-throw CancellationException; catch only real errors. MED R3-5 reactiveFilter did N `.lowercase()` allocations per keystroke. Switched to contains(ignoreCase = true) — zero allocations. CVE-MED-5 FileProvider cache-path was "." (whole cacheDir, including SettingsImport workdirs). Narrowed to "logs/"; LogDump.capture now writes to cacheDir/logs/ to match. CVE-MED-7 Downloader.Request.setTitle was the raw title (bidi-override / control chars possible). Switched to safeTitle. CVE-MED-8 Rust hello_from_rust value-log scrubbed to name_len. Func-LOW-4 recordAllWatches skip-write-on-no-change (`next !== before`). Deferred to a follow-up (not user-facing this ship): R3-MED-6 — Settings setMaxResolution/setThemeMode/setCacheEnabled not atomic via updateAndGet. Inconsistent with toggle() but the Switch UI throttles enough that no real race. R3-MED-8 — Minibar play-button reads live controller.isPlaying instead of listener-tracked. One-frame oscillation on super-fast double-tap. R3-LOW — collectAsState vs collectAsStateWithLifecycle drift. Func-LOW-6 — refreshIfStale isActive check is TOCTOU on a non-existent multi-threaded call surface (LaunchedEffect + button are both Main). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/lib.rs | 5 +- .../com/sulkta/straw/data/HistoryStore.kt | 64 ++++++++++++++----- .../sulkta/straw/data/SubscriptionsStore.kt | 21 +++--- .../straw/feature/channel/ChannelViewModel.kt | 8 ++- .../feature/dataimport/SettingsImport.kt | 9 ++- .../feature/detail/VideoDetailViewModel.kt | 4 +- .../straw/feature/download/Downloader.kt | 6 +- .../feature/feed/SubscriptionFeedViewModel.kt | 55 ++++++++++++---- .../straw/feature/search/SearchViewModel.kt | 12 ++-- .../kotlin/com/sulkta/straw/util/LogDump.kt | 31 ++++++--- strawApp/src/main/res/xml/file_paths.xml | 9 ++- 12 files changed, 167 insertions(+), 61 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 4e4aea201..7b8d1dc1d 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 37 -const val STRAW_VERSION_NAME = "0.1.0-AW" +const val STRAW_VERSION_CODE = 38 +const val STRAW_VERSION_NAME = "0.1.0-AX" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index 1ca13d06c..2329d55ce 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -39,9 +39,12 @@ pub fn init_logging() { } /// Smoke-test entry point — round-trip a string through JNI. +/// Used during the initial UniFFI bring-up; kept for future smoke +/// debugging. Logs shape only — the `name` value never hits logcat +/// because a future caller might pass a real user-supplied string. #[uniffi::export] pub fn hello_from_rust(name: String) -> String { - log::info!("hello_from_rust called with name={}", name); + log::info!("hello_from_rust called name_len={}", name.len()); format!( "hello {} from rust 🦀 (strawcore v{})", name, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 8e944d7c6..30aca3d0c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -61,36 +61,70 @@ class HistoryStore(context: Context) { * oldest→newest. Single SP write — vc=34 audit flagged the * per-row recordWatch in importHistory as a write-storm vector. * - * O(N) on input size, not O(N²). The vc=35 first cut had an - * `add(0, item)` inside a loop walking up to MAX_HISTORY_IMPORT - * (~50k) entries — ArrayList shift over `merged.size` each step, - * a billion+ shifts in the worst case for a final `take(50)` that - * discards 99.9% of the work. Round-2 audit CRIT-R2. + * Walks input newest-first (input is fed oldest-first), filters + * blanks + already-seen videoIds, prepends to current, then takes + * MAX_WATCHES. Imports WIN over older current entries when the + * store is at the cap — the vc=37 first cut silently discarded + * the whole import in that case (round-3 audit HIGH-1). * - * New shape: walk input newest-first (reversed; SettingsImport - * fed oldest-first), filter blanks + already-seen videoIds, take - * up to MAX_WATCHES, prepend to current. Done in one pass with - * the capped output never exceeding MAX_WATCHES. + * Skips the SP write when the resulting list is identical (by + * reference equality after updateAndGet's no-op return) so a + * spam-import on an already-up-to-date store doesn't thrash disk. */ fun recordAllWatches(items: List) { if (items.isEmpty()) return + val before = _watches.value val next = _watches.updateAndGet { current -> val seen = HashSet(current.size + items.size) current.forEach { seen.add(it.videoId) } - val capacity = (MAX_WATCHES - current.size).coerceAtLeast(0) - if (capacity == 0) return@updateAndGet current - val fresh = ArrayList(capacity) - // Walk newest-first; stop as soon as we have capacity. + // Build the import list newest-first. Capped at + // MAX_WATCHES on its own so we don't over-allocate + // even on a 50k-row hostile export. + val fresh = ArrayList(MAX_WATCHES) val it = items.listIterator(items.size) - while (it.hasPrevious() && fresh.size < capacity) { + while (it.hasPrevious() && fresh.size < MAX_WATCHES) { val item = it.previous() if (item.videoId.isBlank()) continue if (!seen.add(item.videoId)) continue fresh.add(item) } + if (fresh.isEmpty()) return@updateAndGet current + // Combine + cap. take() truncates older `current` entries + // when we'd exceed MAX_WATCHES, so imports always land. (fresh + current).take(MAX_WATCHES) } - sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() + if (next !== before) { + sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() + } + } + + /** + * Bulk import for search history. Same pattern as + * recordAllWatches — single SP write regardless of input size. + * vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was + * calling recordSearch per row, producing N SP writes on a + * potentially-100k-row import. + */ + fun recordAllSearches(queries: List) { + if (queries.isEmpty()) return + val before = _searches.value + val next = _searches.updateAndGet { current -> + val seen = HashSet(current.size + queries.size) + current.forEach { seen.add(it.lowercase()) } + val fresh = ArrayList(MAX_SEARCHES) + val it = queries.listIterator(queries.size) + while (it.hasPrevious() && fresh.size < MAX_SEARCHES) { + val q = it.previous().trim() + if (q.isEmpty()) continue + if (!seen.add(q.lowercase())) continue + fresh.add(q) + } + if (fresh.isEmpty()) return@updateAndGet current + (fresh + current).take(MAX_SEARCHES) + } + if (next !== before) { + sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() + } } fun recordSearch(query: String) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 9675b005e..97a3bf994 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -73,22 +73,27 @@ class SubscriptionsStore(context: Context) { * caller can report an "added X" stat. */ fun addAll(refs: List): Int { - // Derive `added` from the size delta INSTEAD of incrementing a - // var inside updateAndGet's lambda — that lambda can re-run - // under CAS contention (a concurrent toggle from the channel - // screen during a 500-row import), and a var-outside-lambda - // accumulates across retries. vc=36 audit CVE HIGH-4. - val cur = _subs.value + // Count NEW refs by checking each input URL against the + // current state's pre-image inside the CAS lambda. Captures + // exactly the additions this call made — concurrent + // toggle()s that race the CAS don't inflate the count (vc=37 + // round-3 audit HIGH-2/CVE-2). The counter lives in an + // AtomicInteger so each lambda re-run resets it correctly. + val counter = java.util.concurrent.atomic.AtomicInteger(0) val next = _subs.updateAndGet { state -> + counter.set(0) val byUrl = state.associateBy { it.url }.toMutableMap() for (r in refs) { if (r.url.isBlank()) continue - if (r.url !in byUrl) byUrl[r.url] = r + if (r.url !in byUrl) { + byUrl[r.url] = r + counter.incrementAndGet() + } } byUrl.values.toList() } persist(next) - return next.size - cur.size + return counter.get() } fun clear() { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 82a3d4245..9bec03cb2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -59,7 +59,13 @@ class ChannelViewModel : ViewModel() { } catch (t: Throwable) { _ui.value = ChannelUiState( loading = false, - error = t.message ?: t.javaClass.simpleName, + // Scrub before storing — UniFFI/Rust exceptions + // can embed full signed googlevideo URLs in the + // message (NetworkError::Recaptcha { url }). vc=37 + // round-3 audit CVE-1. + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index b35185009..8ff1c3aea 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -378,16 +378,21 @@ object SettingsImport { openDb(dbFile).use { db -> // Search history — feed oldest first so the store ends up with // the most-recent on top after its own dedup + take(MAX). + // Stage + bulk-write — vc=37 round-3 audit CVE MED-6: + // per-row recordSearch was N SP writes on potentially + // 100k+ rows. The SELECT also lacked a LIMIT; added now. + val stagedSearches = mutableListOf() db.rawQuery( - "SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC", + "SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC LIMIT 50000", arrayOf(YT_SERVICE_ID.toString()), ).use { c -> while (c.moveToNext()) { val q = c.getString(0) ?: continue - historyStore.recordSearch(q) + stagedSearches += q searchesSeen++ } } + historyStore.recordAllSearches(stagedSearches) // Watch history — newest first via stream_history.access_date, // joined to streams for the metadata we need. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 61e8744b8..602a3fab4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -189,7 +189,9 @@ class VideoDetailViewModel : ViewModel() { } catch (t: Throwable) { _ui.value = VideoDetailUiState( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt index 6ce34060e..b7ac792c2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt @@ -65,7 +65,11 @@ object Downloader { // returned below, so user-facing UX is unaffected. val req = runCatching { DownloadManager.Request(Uri.parse(url)) - .setTitle(title) + // Sanitized title — bidi-overrides and control chars + // in extractor output would otherwise render in + // DownloadsScreen's row title. vc=37 round-3 audit + // CVE MED-7. + .setTitle(safeTitle) .setDescription("Straw — ${kind.name.lowercase()}") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) .setVisibleInDownloadsUi(false) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 2dffb22e9..5a87e73dd 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -25,6 +25,7 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -78,14 +79,24 @@ class SubscriptionFeedViewModel : ViewModel() { if (!Settings.get().cacheEnabled.value) return@launch val saved = withContext(Dispatchers.IO) { FeedCache.get().load() } if (saved.isEmpty()) return@launch - channelCache.putAll(saved) + // putIfAbsent (not putAll) — refresh() may have started + // populating fresh entries during our IO suspension; we + // must not overwrite those with disk-stale values. + // vc=37 round-3 audit CVE-3. + saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) } val channels = Subscriptions.get().subs.value if (channels.isNotEmpty()) { pruneCacheToSubs(channels) - _ui.value = _ui.value.copy( - items = mergeFromCache(channels), - lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L, - ) + val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L + // _ui.update so a concurrent refresh()'s state write + // doesn't race with this copy. vc=37 round-3 audit + // HIGH-4. Only advance lastFetchedAt — never regress. + _ui.update { + it.copy( + items = mergeFromCache(channels), + lastFetchedAt = maxOf(it.lastFetchedAt, savedTs), + ) + } } } } @@ -134,21 +145,23 @@ class SubscriptionFeedViewModel : ViewModel() { } fun refresh() { + // Cancel any in-flight refresh at the TOP — including before + // the empty-channels branch. Without this, a refresh that + // ran on a non-empty sub set could still be writing to + // channelCache when the user unsubscribes from the last + // channel; we'd clear() then immediately repopulate with + // phantom entries when the prior fetchChannelInto resolved. + // vc=37 round-3 audit HIGH-3. + inFlight?.cancel() val channels = Subscriptions.get().subs.value if (channels.isEmpty()) { - _ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) } + _ui.update { it.copy(loading = false, items = emptyList(), error = null) } channelCache.clear() - // Wipe disk too. vc=36 audit B1: previously the disk - // cache kept stale entries indefinitely after the user - // unsubscribed from everything. mergeFromCache eventually - // prunes them on the next merge, but they sat as orphans - // through cold starts in the meantime. viewModelScope.launch(Dispatchers.IO) { runCatching { FeedCache.get().clear() } } return } - inFlight?.cancel() _ui.update { it.copy(loading = true, error = null) } inFlight = viewModelScope.launch { try { @@ -181,10 +194,18 @@ class SubscriptionFeedViewModel : ViewModel() { } } } catch (t: Throwable) { + // Re-throw cancellation so spam-tapping Refresh (or + // toggling cache OFF→ON during a refresh) doesn't + // surface a "refresh failed: StandaloneCoroutineCancelled" + // banner above the cached items. vc=37 round-3 audit + // function-correctness HIGH-1. + if (t is CancellationException) throw t _ui.update { it.copy( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } @@ -264,8 +285,14 @@ class SubscriptionFeedViewModel : ViewModel() { * stayed visible until process death. vc=35 audit MED-C13. */ fun clearInMemoryCache() { + // Cancel any in-flight refresh — without this, fetchChannelInto + // coroutines mid-execution would re-populate the cache after + // the clear. Round-3 audit function MED-3. + inFlight?.cancel() channelCache.clear() - _ui.value = _ui.value.copy(items = emptyList(), lastFetchedAt = 0L) + // Use _ui.update for atomicity vs concurrent refresh writes + // (round-3 audit HIGH-4). + _ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 082d61fdb..bcef3a3a4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -180,7 +180,9 @@ class SearchViewModel : ViewModel() { // the user still has something to look at while offline. _ui.value = _ui.value.copy( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } @@ -193,11 +195,13 @@ class SearchViewModel : ViewModel() { * after each successful submit and at VM construction. */ private fun reactiveFilter(q: String): List { - val needle = q.lowercase() + // contains(ignoreCase=true) on the raw fields avoids the + // 3N+ String allocations per keystroke that `.lowercase()` + // copy-and-compare produced. Round-3 audit MED-5. return pool.asSequence() .filter { item -> - item.title.lowercase().contains(needle) - || item.uploader.lowercase().contains(needle) + item.title.contains(q, ignoreCase = true) + || item.uploader.contains(q, ignoreCase = true) } .take(60) .toList() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index d5279a144..af728aae2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -43,12 +43,16 @@ object LogDump { runCatching { val pid = Process.myPid() val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") - val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp") + // Write to cacheDir/logs/ — vc=37 round-3 audit CVE MED-5 + // narrowed the FileProvider scope from the whole cacheDir + // to just this subdir, so dumps must land here. + val logsDir = File(context.cacheDir, "logs").apply { mkdirs() } + val outFile = File(logsDir, "straw-logs-$timestamp.txt") + val tmpFile = File(logsDir, "straw-logs-$timestamp.txt.tmp") // Sweep old dumps before writing the new one so cacheDir // doesn't grow per export. - context.cacheDir.listFiles { _, name -> + logsDir.listFiles { _, name -> name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp")) }?.forEach { it.delete() } @@ -107,18 +111,27 @@ object LogDump { var s = line // Pre-signed googlevideo URLs: keep host visible, drop path+query. s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") - // Any remaining signed-param shapes that snuck through other URLs. - // Expanded set vc=36 audit CVE MED-2: + n (JS-deobfuscated n-sig), - // lsig (link signature), ei (encrypted event-id), key, sparams. - s = SIGNED_PARAM_RE.replace(s, "$1=") + // Long, distinctive token names — match anywhere. + s = SIGNED_PARAM_LONG_RE.replace(s, "$1=") + // Short single-letter / two-letter tokens — require `[?&]` + // immediately before to avoid eating innocent counters. + s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=") return s } private val GOOGLEVIDEO_URL_RE = Regex( """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) - private val SIGNED_PARAM_RE = Regex( - """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl|n|lsig|ei|key|sparams)=([^&\s"']+)""", + // Long tokens are unique enough to match anywhere. Short tokens + // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before + // so we don't redact innocuous `n=42` counters from other libs. + // vc=37 round-3 audit CVE-4. + private val SIGNED_PARAM_LONG_RE = Regex( + """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""", + RegexOption.IGNORE_CASE, + ) + private val SIGNED_PARAM_SHORT_RE = Regex( + """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) } diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml index 2cd9f5c6f..57446c2ce 100644 --- a/strawApp/src/main/res/xml/file_paths.xml +++ b/strawApp/src/main/res/xml/file_paths.xml @@ -1,8 +1,11 @@ - - + + + with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home as + of vc=42 — was previously inlined in StrawActivity.kt + under YT_HOSTS; drift was caught in the vc=34 function + audit, music.youtube.com etc. were accepted by code but + never offered by the launcher disambig). --> From 5d9cf3e370d4723a8e68364f1ff5f3c852de7d51 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 16:33:50 -0700 Subject: [PATCH 33/87] vc=44: fix back-from-fullscreen showing thumbnail placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: press fullscreen → press system back → VideoDetail re-renders as a "freshly loaded page" — thumbnail with Play button overlay visible, audio continuing from the persistent MediaController. Root cause: `var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }` at VideoDetailScreen.kt:125 keys on streamUrl, so popping back from Player remounts the composable with a fresh false. The thumbnail placeholder Box renders instead of InlinePlayer; audio keeps going on the shared controller because nothing was stopped. Fix: default inlinePlaying to true when the shared controller is already playing this exact stream — almost always the back-from- fullscreen case. Fresh navigation to a video that isn't currently playing still gets the thumbnail+Play placeholder as before. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../sulkta/straw/feature/detail/VideoDetailScreen.kt | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 033ec3f95..839b5c708 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 43 -const val STRAW_VERSION_NAME = "0.1.0-BC" +const val STRAW_VERSION_CODE = 44 +const val STRAW_VERSION_NAME = "0.1.0-BD" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index d28b26527..ea3800279 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -122,7 +122,17 @@ fun VideoDetailScreen( var showDownloadDialog by remember { mutableStateOf(false) } var showSaveToPlaylistDialog by remember { mutableStateOf(false) } // Inline-play state resets when navigating to a different video. - var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } + // BUT: if the shared MediaController is already playing this exact + // stream — most commonly because the user popped back from + // fullscreen Player — default to true so the inline surface picks + // up the running playback instead of dropping back to the + // thumbnail+Play placeholder. Without this, system back from + // fullscreen looked like "the video went to background" — audio + // continued via the persistent controller but the video page + // re-rendered as a freshly-loaded detail. + var inlinePlaying by remember(streamUrl) { + mutableStateOf(NowPlaying.current.value?.streamUrl == streamUrl) + } LaunchedEffect(streamUrl) { vm.load(streamUrl) } // The Background button (and the fullscreen audio-only toggle) From c515fabf71473c24f1e5c0444c15328dc9a2c0bd Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 22:11:12 -0700 Subject: [PATCH 34/87] vc=45: tappable channel name in search + channel row on VideoDetail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search results: - Uploader name split onto its own line at bodyMedium (was bodySmall). - Clickable when uploaderUrl present — taps land on the channel page. - Tinted primary when clickable, neutral when not. - Views/duration moved to a separate line so they don't fight the larger uploader tap target. VideoDetail: - New channel row below the title: avatar (40dp circle, clickable), name (titleSmall semibold, clickable), subscriber count, Subscribe/Subscribed button on the right. - Avatar + subscriber count pulled from the same strawcore.channelInfo call that already runs for moreFromChannel — no extra round-trip. - Opportunistically pushes a fresh avatar back to SubscriptionsStore on resolution so the subs feed picks it up too (mirrors the existing backfill in SubscriptionFeedViewModel.fetchChannelInto). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 1 + .../straw/feature/detail/VideoDetailScreen.kt | 74 ++++++++++++++++--- .../feature/detail/VideoDetailViewModel.kt | 69 ++++++++++++----- .../straw/feature/search/SearchScreen.kt | 57 ++++++++++---- 5 files changed, 160 insertions(+), 45 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 839b5c708..07476195c 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 44 -const val STRAW_VERSION_NAME = "0.1.0-BD" +const val STRAW_VERSION_CODE = 45 +const val STRAW_VERSION_NAME = "0.1.0-BE" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 9bcb0664e..7d5f00e18 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -166,6 +166,7 @@ class StrawActivity : ComponentActivity() { is Screen.Settings -> SettingsScreen() is Screen.Search -> SearchScreen( onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, + onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, ) is Screen.VideoDetail -> VideoDetailScreen( streamUrl = s.streamUrl, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index ea3800279..b2209bcc0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -100,6 +100,8 @@ import com.sulkta.straw.feature.player.NowPlaying import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.LogDump +import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -296,17 +298,69 @@ fun VideoDetailScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) val uploaderUrl = d.uploaderUrl - Text( - text = d.uploader, - style = MaterialTheme.typography.bodyMedium, - color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = if (uploaderUrl != null) Modifier.clickable { - onOpenChannel(uploaderUrl, d.uploader) - } else Modifier, - ) + // Channel row: avatar + name (larger, clickable when we + // have a uploaderUrl) + Subscribe / Subscribed toggle. + // Matches the YouTube/NewPipe layout below the title. + val subs by Subscriptions.get().subs.collectAsStateWithLifecycle() + val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (!d.uploaderAvatar.isNullOrBlank()) { + AsyncImage( + model = d.uploaderAvatar, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .then( + if (uploaderUrl != null) + Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) } + else Modifier + ), + ) + Spacer(modifier = Modifier.width(10.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = d.uploader, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = if (uploaderUrl != null) Modifier + .clickable { onOpenChannel(uploaderUrl, d.uploader) } + .padding(vertical = 4.dp) + else Modifier.padding(vertical = 4.dp), + ) + if (d.uploaderSubscriberCount > 0) { + Text( + text = "${formatCount(d.uploaderSubscriberCount)} subscribers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (uploaderUrl != null) { + val onSubClick = { + Subscriptions.get().toggle( + ChannelRef( + url = uploaderUrl, + name = d.uploader, + avatar = d.uploaderAvatar, + ), + ) + } + if (isSubscribed) { + OutlinedButton(onClick = onSubClick) { Text("Subscribed") } + } else { + Button(onClick = onSubClick) { Text("Subscribe") } + } + } + } Spacer(modifier = Modifier.height(12.dp)) Row( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 33b81c305..7c11976f9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -41,6 +41,15 @@ data class VideoDetail( val title: String, val uploader: String, val uploaderUrl: String?, + /** + * Uploader's channel avatar (square-ish thumbnail). Populated + * from the same strawcore.channelInfo call that fills + * `moreFromChannel`; null until that call resolves, or when the + * uploaderUrl is missing / fails the allowlist. Renders as a + * small circle next to the channel name on VideoDetail. + */ + val uploaderAvatar: String? = null, + val uploaderSubscriberCount: Long = -1, val viewCount: Long, val description: String, val thumbnail: String?, @@ -180,26 +189,48 @@ class VideoDetailViewModel : ViewModel() { // extractor would otherwise trigger an arbitrary-host // network call. Round-4 audit HIGH-4. val uploaderUrl = info.uploaderUrl - val moreFromChannel: List = - if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) emptyList() - else runCatchingCancellable { + data class ChannelExtras( + val avatar: String?, + val subscriberCount: Long, + val videos: List, + ) + val channelExtras: ChannelExtras = + if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) { + ChannelExtras(null, -1, emptyList()) + } else runCatchingCancellable { val ch = uniffi.strawcore.channelInfo(uploaderUrl) - ch.videos - .filter { it.url != streamUrl } - .take(20) - .map { v -> - StreamItem( - url = v.url, - title = v.title.ifBlank { "(no title)" }, - uploader = v.uploader.ifBlank { uploader }, - uploaderUrl = v.uploaderUrl ?: uploaderUrl, - thumbnail = v.thumbnail, - durationSeconds = v.durationSeconds, - viewCount = v.viewCount, - uploadDateRelative = v.uploadDateRelative, - ) + // Opportunistic avatar refresh: if the user is + // subscribed and our stored avatar is stale or + // missing, push the fresh one back to the store + // so the subs feed picks it up too. + val fresh = ch.avatar + if (!fresh.isNullOrBlank()) { + runCatchingCancellable { + com.sulkta.straw.data.Subscriptions + .get().updateAvatar(uploaderUrl, fresh) } - }.getOrDefault(emptyList()) + } + ChannelExtras( + avatar = fresh, + subscriberCount = ch.subscriberCount, + videos = ch.videos + .filter { it.url != streamUrl } + .take(20) + .map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { uploader }, + uploaderUrl = v.uploaderUrl ?: uploaderUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + uploadDateRelative = v.uploadDateRelative, + ) + }, + ) + }.getOrDefault(ChannelExtras(null, -1, emptyList())) + val moreFromChannel = channelExtras.videos val resolved = resolvePlayback(info, segments) @@ -217,6 +248,8 @@ class VideoDetailViewModel : ViewModel() { title = title, uploader = uploader, uploaderUrl = info.uploaderUrl, + uploaderAvatar = channelExtras.avatar, + uploaderSubscriberCount = channelExtras.subscriberCount, viewCount = info.viewCount, description = info.description, thumbnail = thumb, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index c0c7a3434..3a8b94ea2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -50,6 +50,7 @@ import com.sulkta.straw.util.formatViews @Composable fun SearchScreen( onOpenVideo: (url: String, title: String) -> Unit, + onOpenChannel: (url: String, name: String) -> Unit, vm: SearchViewModel = viewModel(), ) { val state by vm.ui.collectAsStateWithLifecycle() @@ -149,7 +150,11 @@ fun SearchScreen( } LazyColumn(modifier = Modifier.fillMaxSize()) { items(state.results) { item -> - ResultRow(item = item) { onOpenVideo(item.url, item.title) } + ResultRow( + item = item, + onClick = { onOpenVideo(item.url, item.title) }, + onChannelClick = { url -> onOpenChannel(url, item.uploader) }, + ) HorizontalDivider() } } @@ -159,7 +164,11 @@ fun SearchScreen( } @Composable -private fun ResultRow(item: StreamItem, onClick: () -> Unit) { +private fun ResultRow( + item: StreamItem, + onClick: () -> Unit, + onChannelClick: (url: String) -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() @@ -185,23 +194,41 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) { overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) + // Uploader on its own line — larger + tinted + clickable + // when we have a uploaderUrl to route to. Tapping the + // name jumps to the Channel screen; tapping anywhere else + // on the row still opens the video. Child clickable + // consumes the press before the row's clickable hears it. + val uploaderUrl = item.uploaderUrl Text( - text = buildString { - append(item.uploader) - if (item.viewCount > 0) { - append(" · ") - append(formatViews(item.viewCount)) - } - if (item.durationSeconds > 0) { - append(" · ") - append(formatDuration(item.durationSeconds)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = item.uploader, + style = MaterialTheme.typography.bodyMedium, + color = if (!uploaderUrl.isNullOrBlank()) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = if (!uploaderUrl.isNullOrBlank()) + Modifier + .clickable { onChannelClick(uploaderUrl) } + .padding(vertical = 4.dp) + else + Modifier.padding(vertical = 4.dp), ) + if (item.viewCount > 0 || item.durationSeconds > 0) { + Text( + text = buildString { + if (item.viewCount > 0) append(formatViews(item.viewCount)) + if (item.viewCount > 0 && item.durationSeconds > 0) append(" · ") + if (item.durationSeconds > 0) append(formatDuration(item.durationSeconds)) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } From c3583457fb85934dedfe071af1d956b73f9c7925 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 04:28:14 -0700 Subject: [PATCH 35/87] =?UTF-8?q?vc=3D46:=20long-press=20video=20actions?= =?UTF-8?q?=20=E2=80=94=20save=20to=20playlist=20+=20share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build offline playlists from any list, not just one-at-a-time from VideoDetail. * Long-press any video row → ModalBottomSheet with title/uploader header + actions: Save to playlist, Share. * Save reuses the existing playlist dialog (extracted out of VideoDetailScreen into feature/playlist/VideoActions.kt and promoted to public). * Share fires a system ACTION_SEND with the YT URL. * Wired across all 5 video-row sites: Search results, Subs feed, History, Channel videos, Related/More-from-channel on VideoDetail. Deferred to next ship: * "Play next" / "Add to queue" — needs Media3 queue substrate + per-item streamInfo resolution path. Separate ticket; non-blocking for the offline-playlist build flow Cobb asked for tonight. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 56 +++- .../straw/feature/channel/ChannelScreen.kt | 33 ++- .../straw/feature/detail/VideoDetailScreen.kt | 125 +++------ .../straw/feature/playlist/VideoActions.kt | 256 ++++++++++++++++++ .../straw/feature/search/SearchScreen.kt | 22 +- 6 files changed, 396 insertions(+), 100 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 07476195c..cd111e2a1 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 45 -const val STRAW_VERSION_NAME = "0.1.0-BE" +const val STRAW_VERSION_CODE = 46 +const val STRAW_VERSION_NAME = "0.1.0-BF" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 2461a8182..2170bb823 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -8,8 +8,10 @@ package com.sulkta.straw +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -78,6 +80,8 @@ import com.sulkta.straw.data.History import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel +import com.sulkta.straw.feature.playlist.VideoActionTarget +import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.util.formatDuration @@ -234,6 +238,10 @@ fun StrawHome( @Composable private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) { val watches by History.get().watches.collectAsState() + var actionTarget by remember { mutableStateOf(null) } + actionTarget?.let { t -> + VideoActionsSheet(target = t, onDismiss = { actionTarget = null }) + } Column { Text( @@ -252,7 +260,18 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) { } else { LazyColumn { items(watches) { w -> - RecentRow(w) { onOpenVideo(w.url, w.title) } + RecentRow( + item = w, + onClick = { onOpenVideo(w.url, w.title) }, + onLongClick = { + actionTarget = VideoActionTarget( + streamUrl = w.url, + title = w.title, + uploader = w.uploader, + thumbnail = w.thumbnail, + ) + }, + ) HorizontalDivider() } } @@ -269,6 +288,10 @@ private fun SubsPane( val subs by Subscriptions.get().subs.collectAsState() val feed by feedVm.ui.collectAsState() val watches by History.get().watches.collectAsState() + var actionTarget by remember { mutableStateOf(null) } + actionTarget?.let { t -> + VideoActionsSheet(target = t, onDismiss = { actionTarget = null }) + } LaunchedEffect(subs) { feedVm.refreshIfStale() } // Filter + pagination state. hideWatched is sticky for the session @@ -415,7 +438,18 @@ private fun SubsPane( } LazyColumn(state = listState) { items(displayed) { item -> - FeedRow(item) { onOpenVideo(item.url, item.title) } + FeedRow( + item = item, + onClick = { onOpenVideo(item.url, item.title) }, + onLongClick = { + actionTarget = VideoActionTarget( + streamUrl = item.url, + title = item.title, + uploader = item.uploader, + thumbnail = item.thumbnail, + ) + }, + ) HorizontalDivider() } if (hasMore) { @@ -456,12 +490,17 @@ private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$") private fun extractVideoId(url: String): String = VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty() +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun FeedRow(item: StreamItem, onClick: () -> Unit) { +private fun FeedRow( + item: StreamItem, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { @@ -586,12 +625,17 @@ private fun SubChip( } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) { +private fun RecentRow( + item: WatchHistoryItem, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 9d3e57143..54e41dd0f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -5,7 +5,9 @@ package com.sulkta.straw.feature.channel +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -41,11 +43,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.Subscriptions +import com.sulkta.straw.feature.playlist.VideoActionTarget +import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatDuration @@ -61,6 +68,10 @@ fun ChannelScreen( LaunchedEffect(channelUrl) { vm.load(channelUrl) } val subs by Subscriptions.get().subs.collectAsState() val subscribed = subs.any { it.url == channelUrl } + var actionTarget by remember { mutableStateOf(null) } + actionTarget?.let { t -> + VideoActionsSheet(target = t, onDismiss = { actionTarget = null }) + } when { state.loading -> Box( @@ -130,19 +141,35 @@ fun ChannelScreen( HorizontalDivider() } items(state.videos) { item -> - ChannelVideoRow(item) { onOpenVideo(item.url, item.title) } + ChannelVideoRow( + item = item, + onClick = { onOpenVideo(item.url, item.title) }, + onLongClick = { + actionTarget = VideoActionTarget( + streamUrl = item.url, + title = item.title, + uploader = item.uploader, + thumbnail = item.thumbnail, + ) + }, + ) HorizontalDivider() } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) { +private fun ChannelVideoRow( + item: StreamItem, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.Top, ) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index b2209bcc0..8cb277a6c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -17,8 +17,10 @@ import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring 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.combinedClickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -92,7 +94,7 @@ import coil3.compose.AsyncImage import com.sulkta.straw.OverlayChromeColor import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.data.PlaylistItem -import com.sulkta.straw.data.Playlists +import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog import com.sulkta.straw.feature.download.DownloadKind import com.sulkta.straw.feature.download.Downloader import com.sulkta.straw.feature.player.LocalStrawController @@ -123,6 +125,13 @@ fun VideoDetailScreen( val activity = context as? Activity var showDownloadDialog by remember { mutableStateOf(false) } var showSaveToPlaylistDialog by remember { mutableStateOf(false) } + var actionTarget by remember { mutableStateOf(null) } + actionTarget?.let { t -> + com.sulkta.straw.feature.playlist.VideoActionsSheet( + target = t, + onDismiss = { actionTarget = null }, + ) + } // Inline-play state resets when navigating to a different video. // BUT: if the shared MediaController is already playing this exact // stream — most commonly because the user popped back from @@ -536,7 +545,18 @@ fun VideoDetailScreen( ) Spacer(modifier = Modifier.height(8.dp)) d.related.take(20).forEach { rel -> - RelatedRow(rel) { onOpenVideo(rel.url, rel.title) } + RelatedRow( + item = rel, + onClick = { onOpenVideo(rel.url, rel.title) }, + onLongClick = { + actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget( + streamUrl = rel.url, + title = rel.title, + uploader = rel.uploader, + thumbnail = rel.thumbnail, + ) + }, + ) HorizontalDivider() } } @@ -551,7 +571,18 @@ fun VideoDetailScreen( ) Spacer(modifier = Modifier.height(8.dp)) d.moreFromChannel.take(20).forEach { item -> - RelatedRow(item) { onOpenVideo(item.url, item.title) } + RelatedRow( + item = item, + onClick = { onOpenVideo(item.url, item.title) }, + onLongClick = { + actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget( + streamUrl = item.url, + title = item.title, + uploader = item.uploader, + thumbnail = item.thumbnail, + ) + }, + ) HorizontalDivider() } } @@ -629,15 +660,17 @@ fun VideoDetailScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun RelatedRow( item: StreamItem, onClick: () -> Unit, + onLongClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { @@ -676,90 +709,6 @@ private fun RelatedRow( } } -@Composable -private fun SaveToPlaylistDialog( - item: PlaylistItem, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val store = Playlists.get() - val playlists by store.playlists.collectAsState() - var creatingNew by remember { mutableStateOf(false) } - var newName by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Save to playlist") }, - text = { - Column { - if (playlists.isEmpty() && !creatingNew) { - Text( - "No playlists yet. Create one to save this video.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - playlists.forEach { pl -> - val already = pl.items.any { it.streamUrl == item.streamUrl } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !already) { - store.addItem(pl.id, item) - Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show() - onDismiss() - } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(pl.name, style = MaterialTheme.typography.bodyLarge) - Text( - "${pl.items.size} video${if (pl.items.size == 1) "" else "s"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - HorizontalDivider() - } - if (creatingNew) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text("New playlist name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - val pl = store.create(newName) - store.addItem(pl.id, item) - Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show() - onDismiss() - }) { Text("Create + save") } - OutlinedButton(onClick = { creatingNew = false; newName = "" }) { - Text("Cancel") - } - } - } else { - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton(onClick = { creatingNew = true }) { - Text("+ New playlist") - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text("Close") } - }, - ) -} - /** * Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders * a PlayerView bound to the shared LocalStrawController — the same diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt new file mode 100644 index 000000000..e57433666 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Shared long-press actions surface for video rows. The menu shows + * "Save to playlist" + "Share" (and Add-to-Queue later when the queue + * substrate lands). Every video row in the app — Search results, + * Subs feed, Channel videos, History, Related — calls + * `showVideoActions(...)` from a `combinedClickable.onLongClick`. + * + * Pure-Compose surface — no ViewModel needed; PlaylistsStore is a + * process-wide singleton and the share Intent is a fire-and-forget + * Android system action. + */ + +package com.sulkta.straw.feature.playlist + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.sulkta.straw.data.PlaylistItem +import com.sulkta.straw.data.Playlists + +/** + * Minimal video descriptor for the actions sheet. Avoids dragging + * the full search.StreamItem (which has extractor fields the + * actions don't need) so the same surface can be invoked from + * history rows where we only have a WatchHistoryItem. + */ +data class VideoActionTarget( + val streamUrl: String, + val title: String, + val uploader: String, + val thumbnail: String?, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoActionsSheet( + target: VideoActionTarget, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState() + var showSaveDialog by remember { mutableStateOf(false) } + + if (showSaveDialog) { + SaveToPlaylistDialog( + item = PlaylistItem( + streamUrl = target.streamUrl, + title = target.title, + thumbnail = target.thumbnail, + uploader = target.uploader, + ), + onDismiss = { + showSaveDialog = false + onDismiss() + }, + ) + return + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + // Title row — truncated to one line, gives context for the + // actions below. + Text( + text = target.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + if (target.uploader.isNotBlank()) { + Text( + text = target.uploader, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + ActionRow( + icon = Icons.Filled.PlaylistAdd, + label = "Save to playlist", + onClick = { showSaveDialog = true }, + ) + ActionRow( + icon = Icons.Filled.Share, + label = "Share", + onClick = { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, target.streamUrl) + putExtra(Intent.EXTRA_SUBJECT, target.title) + } + context.startActivity( + Intent.createChooser(send, "Share video").addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK, + ), + ) + onDismiss() + }, + ) + } + } +} + +@Composable +private fun ActionRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +/** + * Shared "Save to playlist" dialog — was previously inline in + * VideoDetailScreen; promoted to its own file so the long-press + * menu on any row can reuse it. + */ +@Composable +fun SaveToPlaylistDialog( + item: PlaylistItem, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val store = Playlists.get() + val playlists by store.playlists.collectAsState() + var creatingNew by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Save to playlist") }, + text = { + Column { + if (playlists.isEmpty() && !creatingNew) { + Text( + "No playlists yet. Create one to save this video.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + playlists.forEach { pl -> + val already = pl.items.any { it.streamUrl == item.streamUrl } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !already) { + store.addItem(pl.id, item) + Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show() + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(pl.name, style = MaterialTheme.typography.bodyLarge) + Text( + "${pl.items.size} video${if (pl.items.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + if (creatingNew) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("New playlist name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + val pl = store.create(newName) + store.addItem(pl.id, item) + Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show() + onDismiss() + }) { Text("Create + save") } + OutlinedButton(onClick = { creatingNew = false; newName = "" }) { + Text("Cancel") + } + } + } else { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = { creatingNew = true }) { + Text("+ New playlist") + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 3a8b94ea2..3c9336fdd 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -5,7 +5,9 @@ package com.sulkta.straw.feature.search +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -44,6 +46,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.sulkta.straw.data.History +import com.sulkta.straw.feature.playlist.VideoActionTarget +import com.sulkta.straw.feature.playlist.VideoActionsSheet +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews @@ -55,6 +61,10 @@ fun SearchScreen( ) { val state by vm.ui.collectAsStateWithLifecycle() val recentSearches by History.get().searches.collectAsState() + var actionTarget by remember { mutableStateOf(null) } + actionTarget?.let { target -> + VideoActionsSheet(target = target, onDismiss = { actionTarget = null }) + } Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) { OutlinedTextField( @@ -153,6 +163,14 @@ fun SearchScreen( ResultRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, + onLongClick = { + actionTarget = VideoActionTarget( + streamUrl = item.url, + title = item.title, + uploader = item.uploader, + thumbnail = item.thumbnail, + ) + }, onChannelClick = { url -> onOpenChannel(url, item.uploader) }, ) HorizontalDivider() @@ -163,16 +181,18 @@ fun SearchScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ResultRow( item: StreamItem, onClick: () -> Unit, + onLongClick: () -> Unit, onChannelClick: (url: String) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(vertical = 10.dp), verticalAlignment = Alignment.Top, ) { From 406fd8924a247f4715739ca9031f9ec59223bd8d Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 04:33:16 -0700 Subject: [PATCH 36/87] fixup vc=46: missing remember/getValue imports in SearchScreen --- .../main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 3c9336fdd..9dfed4063 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -48,7 +48,9 @@ import coil3.compose.AsyncImage import com.sulkta.straw.data.History import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews From 02381edf0394f343212f645723892e9b8e146bac Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 05:39:19 -0700 Subject: [PATCH 37/87] =?UTF-8?q?vc=3D47:=20queue=20=E2=80=94=20Play=20nex?= =?UTF-8?q?t=20+=20Add=20to=20queue=20from=20long-press=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now you can line up videos. Long-press → "Play next" inserts right after the current playing item; "Add to queue" appends to the back. Media3 auto-advances through the queue when each item ends. Implementation: * feature/player/Queue.kt — process-wide MutableStateFlow> that mirrors the controller's MediaItem list 1:1 by index. Append, insertAt, setAll mutators match how the controller's media items get mutated. * StrawMediaController.enqueueNext / enqueueLast — build the MediaItem (same shape as setPlayingFrom), insert into Queue at the corresponding index, then controller.addMediaItem. Empty- queue fallback: route through setPlayingFrom (starts playback immediately) so the user doesn't get a silent no-op. * PlaybackService Player.Listener.onMediaItemTransition — on auto- advance, look up the new index in Queue, push the matching NowPlayingItem into NowPlaying via claim(). Minibar + the SB skip-loop (both reactive to NowPlaying.current) reflect the new track without any extra wiring. * feature/detail/StreamResolution.kt — extracted the resolveStreamPlayback function out of VideoDetailViewModel so the queue path can call it for each new item. * VideoActionsSheet wires Play next / Add to queue rows that launch into StrawApp.globalScope (process-scoped) — sheet dismisses immediately for snappy UX, but the strawcore network resolve lives in a scope that outlives the sheet. Toast on completion. * StrawApp exposes appScope via a companion `globalScope` for fire-and-forget work that needs to outlive Composition. Known limitation: * SponsorBlock segments are not fetched for queued items — they play through without skips. The originally-played item (added via setPlayingFrom) still gets SB. Folding in lazy per-item SB fetch on transition is a follow-up. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 16 ++++ .../straw/feature/detail/StreamResolution.kt | 47 +++++++++++ .../feature/detail/VideoDetailViewModel.kt | 30 +------ .../straw/feature/player/PlaybackService.kt | 15 ++++ .../com/sulkta/straw/feature/player/Queue.kt | 61 ++++++++++++++ .../feature/player/StrawMediaController.kt | 79 ++++++++++++++++--- .../straw/feature/playlist/VideoActions.kt | 67 +++++++++++++++- 8 files changed, 278 insertions(+), 41 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index cd111e2a1..d0bd6a3da 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 46 -const val STRAW_VERSION_NAME = "0.1.0-BF" +const val STRAW_VERSION_CODE = 47 +const val STRAW_VERSION_NAME = "0.1.0-BG" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 6634cee17..2868d58ac 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -35,6 +35,22 @@ class StrawApp : Application() { }, ) + companion object { + /** Process-scoped coroutine scope — survives Composition + ViewModel + * teardown. Use for fire-and-forget work like long-press + * "Add to queue" that needs to outlive the UI surface that + * triggered it. */ + lateinit var globalScope: CoroutineScope + private set + } + + init { + // The companion lateinit guarantees the same StrawApp instance + // is the only one that sets globalScope — Application is a + // process-singleton on Android. + globalScope = appScope + } + override fun onCreate() { super.onCreate() // Path C-7: route Rust `log::*` calls into Android logcat under tag diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt new file mode 100644 index 000000000..05325bffd --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Pick the playable URLs from a strawcore StreamInfo. Lives outside + * VideoDetailViewModel so the queue path can call it too. + */ + +package com.sulkta.straw.feature.detail + +import com.sulkta.straw.data.Settings +import com.sulkta.straw.net.SbSegment + +/** + * Convert a raw strawcore.StreamInfo into the picked-URL DTO the + * MediaController wants. Honors Settings.maxResolution — cap-fit if + * possible, otherwise the closest-to-cap fallback (lowest height) so + * we don't blow a user's data plan when only above-cap streams exist. + * + * `segments` is the SponsorBlock list to bake into the resulting + * ResolvedPlayback; pass emptyList() when no SB is desired (the queue + * path doesn't pre-fetch SB for queued items). + */ +fun resolveStreamPlayback( + info: uniffi.strawcore.StreamInfo, + segments: List = emptyList(), +): ResolvedPlayback { + val maxRes = Settings.get().maxResolution.value.ceiling + fun pickVideo(streams: List): String? { + if (streams.isEmpty()) return null + val capped = streams.filter { it.height <= maxRes } + return if (capped.isNotEmpty()) { + capped.maxByOrNull { it.bitrate }?.url + } else { + streams.minByOrNull { it.height }?.url + } + } + return ResolvedPlayback( + title = info.title, + videoUrl = pickVideo(info.videoOnly), + audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url, + combinedUrl = pickVideo(info.combined), + dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, + hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, + segments = segments, + ) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 7c11976f9..45336a6bc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -280,33 +280,5 @@ class VideoDetailViewModel : ViewModel() { private fun resolvePlayback( info: uniffi.strawcore.StreamInfo, segments: List, - ): ResolvedPlayback { - val maxRes = Settings.get().maxResolution.value.ceiling - // Pick the highest-bitrate stream that still fits the user's - // cap. Fallback: when every available stream EXCEEDS the cap - // (e.g. a 1080p-only upload with the user on a 480p cap), pick - // the LOWEST-height one — that's the closest-to-cap option and - // honors the user's intent ("don't blow my data plan") even - // when their exact target isn't available. vc=34 audit Q-8 — - // previously this fell back to max-bitrate, which was the - // worst possible choice for someone on a 480p cap. - fun pickVideo(streams: List): String? { - if (streams.isEmpty()) return null - val capped = streams.filter { it.height <= maxRes } - return if (capped.isNotEmpty()) { - capped.maxByOrNull { it.bitrate }?.url - } else { - streams.minByOrNull { it.height }?.url - } - } - return ResolvedPlayback( - title = info.title, - videoUrl = pickVideo(info.videoOnly), - audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url, - combinedUrl = pickVideo(info.combined), - dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, - hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, - segments = segments, - ) - } + ): ResolvedPlayback = resolveStreamPlayback(info, segments) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 95902fa6d..7af5a161f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -98,6 +98,21 @@ class PlaybackService : MediaSessionService() { .setId(MEDIA_SESSION_ID) .setSessionActivity(sessionActivityIntent) .build() + + // Queue auto-advance bridge: when Media3 transitions to the + // next item in the queue, look up the matching NowPlayingItem + // (with original streamUrl, uploader, thumbnail, SB segments) + // and push it into NowPlaying so the minibar + SponsorBlock + // skip-loop reflect the new track. claim() handles concurrent + // setPlayingFrom races — see vc=35 audit HIGH-C6. + player.addListener(object : Player.Listener { + override fun onMediaItemTransition(item: MediaItem?, reason: Int) { + if (item == null) return + val idx = player.currentMediaItemIndex + val queued = Queue.at(idx) ?: return + NowPlaying.claim(queued) + } + }) } override fun onGetSession( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt new file mode 100644 index 000000000..eb6a68618 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Process-wide upcoming-videos queue. Mirrors the MediaController's + * MediaItem list 1:1 by index — Position 0 is "currently playing", + * Position 1+ is "up next". Decoupled from the controller because: + * + * - The controller stores MediaItem (URL + Media3 metadata only). + * We need the original streamUrl, uploader, thumbnail, and + * SponsorBlock segments. NowPlayingItem carries all of that. + * - Media3's onMediaItemTransition fires when the engine auto- + * advances. PlaybackService listens, looks up the new index here, + * and pushes the resolved NowPlayingItem into NowPlaying so the + * minibar + SponsorBlock skip-loop reflect the new track. + * + * Append-only + setAll: no remove/reorder for v1. Mirrors how + * `addMediaItem` / `setMediaItem` mutate the controller. If we ever + * add a queue UI with drag-reorder, that'll need a sync layer. + */ + +package com.sulkta.straw.feature.player + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +object Queue { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + /** Replace the queue — used by setPlayingFrom when starting fresh. */ + fun setAll(item: NowPlayingItem) { + _items.value = listOf(item) + } + + fun append(item: NowPlayingItem) { + _items.update { it + item } + } + + /** + * Insert at the given position (relative to the controller's + * indices). Used by "Play next" — inserts right after the + * currently-playing item. + */ + fun insertAt(index: Int, item: NowPlayingItem) { + _items.update { current -> + val mut = current.toMutableList() + mut.add(index.coerceIn(0, mut.size), item) + mut.toList() + } + } + + /** Read the item at the given controller index, or null on OOB. */ + fun at(index: Int): NowPlayingItem? = _items.value.getOrNull(index) + + fun clear() { + _items.value = emptyList() + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 5c3013c5a..127488aa8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -83,25 +83,86 @@ fun MediaController.setPlayingFrom( startPositionMs: Long = 0L, ) { val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return + val nowPlayingItem = NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = resolved.segments, + ) // Atomic claim BEFORE any controller mutation. If a concurrent // caller already set this URL (inline player + fullscreen Player // racing each other on the same transition), we bail before // double-priming the player. vc=35 audit HIGH-C6. - val claimed = NowPlaying.claim( - NowPlayingItem( - streamUrl = streamUrl, - title = title, - uploader = uploader, - thumbnail = thumbnail, - segments = resolved.segments, - ), - ) + val claimed = NowPlaying.claim(nowPlayingItem) if (!claimed) return + // Replace the queue when starting fresh — Queue mirrors the + // controller's MediaItem list 1:1 by index. If the user later + // long-press-enqueues more items, append/insertAt keep them + // synced. + Queue.setAll(nowPlayingItem) setMediaItem(mediaItem, startPositionMs) prepare() playWhenReady = true } +/** + * Add a video to the playback queue right after the currently-playing + * item. If the player is idle (no current item), fall through to a + * setPlayingFrom that starts playback immediately. The caller already + * resolved playback (strawcore.streamInfo → ResolvedPlayback). + * + * Returns true if the item was enqueued or started; false on a build + * failure (no playable stream in `resolved`). + */ +@UnstableApi +fun MediaController.enqueueNext( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = true) + +/** Append to the back of the queue. Same idle-fallback as enqueueNext. */ +@UnstableApi +fun MediaController.enqueueLast( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = false) + +@UnstableApi +private fun MediaController.enqueueInternal( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, + asNext: Boolean, +): Boolean { + val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false + val item = NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = resolved.segments, + ) + // Empty queue — there's nothing to "enqueue" onto. Treat as a + // start-playing-now and route through the normal claim path. + if (mediaItemCount == 0) { + setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved) + return true + } + val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount + Queue.insertAt(insertIndex, item) + addMediaItem(insertIndex, mediaItem) + return true +} + @UnstableApi private fun buildMediaItem( title: String, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt index e57433666..51138d738 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.PlaylistPlay +import androidx.compose.material.icons.filled.QueueMusic import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -51,8 +53,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import com.sulkta.straw.StrawApp import com.sulkta.straw.data.PlaylistItem import com.sulkta.straw.data.Playlists +import com.sulkta.straw.feature.detail.resolveStreamPlayback +import com.sulkta.straw.feature.player.LocalStrawController +import com.sulkta.straw.feature.player.enqueueLast +import com.sulkta.straw.feature.player.enqueueNext +import com.sulkta.straw.util.runCatchingCancellable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Minimal video descriptor for the actions sheet. Avoids dragging @@ -67,16 +79,59 @@ data class VideoActionTarget( val thumbnail: String?, ) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, UnstableApi::class) @Composable fun VideoActionsSheet( target: VideoActionTarget, onDismiss: () -> Unit, ) { val context = LocalContext.current + val controller = LocalStrawController.current + // Use the process scope — rememberCoroutineScope dies when the + // sheet dismisses, and we dismiss the sheet BEFORE the strawcore + // network round-trip completes (so the user gets immediate + // feedback). Process scope keeps the in-flight resolve alive. val sheetState = rememberModalBottomSheetState() var showSaveDialog by remember { mutableStateOf(false) } + /** Resolve a streamUrl → ResolvedPlayback + call the supplied + * enqueue method. Network resolution is the slow part; wrap it + * in runCatchingCancellable so the rememberCoroutineScope dying + * on sheet-dismiss propagates cleanly. */ + fun enqueue(asNext: Boolean) { + val c = controller + if (c == null) { + Toast.makeText(context, "player not ready yet", Toast.LENGTH_SHORT).show() + return + } + Toast.makeText(context, "Resolving…", Toast.LENGTH_SHORT).show() + val appContext = context.applicationContext + onDismiss() + StrawApp.globalScope.launch { + runCatchingCancellable { + val info = uniffi.strawcore.streamInfo(target.streamUrl) + val resolved = resolveStreamPlayback(info) + withContext(Dispatchers.Main) { + val ok = if (asNext) { + c.enqueueNext(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved) + } else { + c.enqueueLast(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved) + } + val msg = if (ok) { + if (asNext) "Will play next" else "Added to queue" + } else { + "no playable stream" + } + Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show() + } + }.onFailure { + withContext(Dispatchers.Main) { + Toast.makeText(appContext, "queue failed", Toast.LENGTH_SHORT).show() + } + } + } + } + if (showSaveDialog) { SaveToPlaylistDialog( item = PlaylistItem( @@ -116,6 +171,16 @@ fun VideoActionsSheet( } Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() + ActionRow( + icon = Icons.Filled.PlaylistPlay, + label = "Play next", + onClick = { enqueue(asNext = true) }, + ) + ActionRow( + icon = Icons.Filled.QueueMusic, + label = "Add to queue", + onClick = { enqueue(asNext = false) }, + ) ActionRow( icon = Icons.Filled.PlaylistAdd, label = "Save to playlist", From 964bcddb3a1cb909bdff0a366b329418c4b28262 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 07:04:35 -0700 Subject: [PATCH 38/87] vc=48: autoplay (off / same-channel / yt-related) + SB for queued items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two requested features in one ship. Autoplay: * Settings → Autoplay section. Three modes: Off, Same channel, YouTube related. Default Same channel — per Cobb 2026-05-26, "plays next account's video". * Skip-already-watched toggle, default on. Autoplay picks the first un-watched candidate (filters History.watches by videoId). * When STATE_ENDED fires and the queue has no next item, PlaybackService's autoplay handler picks a candidate per mode, resolves it via strawcore, and enqueues — which auto-starts because the queue is empty (enqueueLast routes through setPlayingFrom in that case). * SameChannel calls strawcore.channelInfo(uploaderUrl).take(1). Plumbed NowPlayingItem.uploaderUrl + setPlayingFrom/enqueueLast sig to carry it forward so the autoplay handler has what it needs without re-resolving. * YtRelated re-resolves the current streamInfo and picks info.related[0]. strawcore returns empty for related today, so YtRelated falls open to no-op until that extractor work lands — documented in the AutoplayMode enum help text. SponsorBlock for queued items: * The vc=47 known-limitation. Now: when onMediaItemTransition surfaces a queued item with empty SB segments, fire a background fetch of SB for that video, then NowPlaying.claim() again with the freshened segments. The skip-loop (reactive on NowPlaying.current.segments) picks them up. * Fetch lives in StrawApp.globalScope — outlives the controller transition + sheet UI. Refactor: * StrawMediaController extensions retyped from MediaController → Player so PlaybackService can call them on the ExoPlayer directly. MediaController IS a Player so all existing UI call sites continue to work. * Shared extractYtVideoId util in feature/detail/StreamResolution.kt. The duplicate VIDEO_ID_RE in StrawHome.kt will fold into it next time that file is touched. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../com/sulkta/straw/data/SettingsStore.kt | 47 ++++++ .../straw/feature/detail/StreamResolution.kt | 15 ++ .../straw/feature/detail/VideoDetailScreen.kt | 3 + .../sulkta/straw/feature/player/NowPlaying.kt | 8 + .../straw/feature/player/PlaybackService.kt | 138 ++++++++++++++++++ .../straw/feature/player/PlayerScreen.kt | 1 + .../feature/player/StrawMediaController.kt | 21 ++- .../straw/feature/settings/SettingsScreen.kt | 67 +++++++++ 9 files changed, 295 insertions(+), 9 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index d0bd6a3da..a155f1f75 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 47 -const val STRAW_VERSION_NAME = "0.1.0-BG" +const val STRAW_VERSION_CODE = 48 +const val STRAW_VERSION_NAME = "0.1.0-BH" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 6f01965ac..2580f8128 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -43,11 +43,28 @@ enum class ThemeMode(val label: String) { Dark("Dark"), } +/** + * When a video ends with nothing left in the queue, what should the + * player do? `Off` stops at the end (matches NewPipe's default). + * `SameChannel` chains to the next video from the same uploader — + * fits Straw's user-curated ethos (you opted into this channel). + * `YtRelated` pulls from `info.related` (YouTube's algorithmic + * suggestion); deferred until strawcore populates `related` from + * the /next response — for now it's identical to `Off`. + */ +enum class AutoplayMode(val label: String, val help: String) { + Off("Off", "Stop at the end."), + SameChannel("Same channel", "Play the next video from the same uploader."), + YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"), +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_MAX_RES = "max_resolution_v1" private const val KEY_THEME = "theme_mode_v1" private const val KEY_CACHE_ENABLED = "cache_enabled_v1" +private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1" +private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -64,6 +81,14 @@ class SettingsStore(context: Context) { private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true)) val cacheEnabled: StateFlow = _cacheEnabled.asStateFlow() + private val _autoplayMode = MutableStateFlow(loadAutoplayMode()) + val autoplayMode: StateFlow = _autoplayMode.asStateFlow() + + private val _autoplaySkipWatched = MutableStateFlow( + sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true), + ) + val autoplaySkipWatched: StateFlow = _autoplaySkipWatched.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -98,6 +123,20 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply() } + fun setAutoplayMode(mode: AutoplayMode) { + val before = _autoplayMode.value + if (before == mode) return + _autoplayMode.value = mode + sp.edit().putString(KEY_AUTOPLAY_MODE, mode.name).apply() + } + + fun setAutoplaySkipWatched(skip: Boolean) { + val before = _autoplaySkipWatched.value + if (before == skip) return + _autoplaySkipWatched.value = skip + sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { @@ -117,6 +156,14 @@ class SettingsStore(context: Context) { val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System } + + private fun loadAutoplayMode(): AutoplayMode { + // Default to SameChannel — user explicitly chose "on by default, + // plays next account's video" 2026-05-26. Off-by-default doesn't + // fit the workflow (queue empties → silence). + val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel + return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel + } } object Settings { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt index 05325bffd..d3daaac3e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -11,6 +11,21 @@ package com.sulkta.straw.feature.detail import com.sulkta.straw.data.Settings import com.sulkta.straw.net.SbSegment +/** + * Extract the YouTube video ID from a watch URL. Handles the common + * forms: `youtube.com/watch?v=XXXXXXXXXXX`, `youtu.be/X...`, and + * `youtube.com/shorts/X...`. Returns null when nothing matches. + * + * Centralized here so the autoplay + history + import paths all + * resolve videoIds the same way. Duplicates an earlier per-file regex + * (`StrawHome.kt:VIDEO_ID_RE`) — that one can fold into this when next + * touched. + */ +private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$") + +fun extractYtVideoId(url: String): String? = + VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() } + /** * Convert a raw strawcore.StreamInfo into the picked-URL DTO the * MediaController wants. Honors Settings.maxResolution — cap-fit if diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 8cb277a6c..fb2f9aaef 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -437,6 +437,7 @@ fun VideoDetailScreen( uploader = d.uploader, thumbnail = d.thumbnail, resolved = r, + uploaderUrl = d.uploaderUrl, ) } // Audio-only: drop video track. Foreground @@ -488,6 +489,7 @@ fun VideoDetailScreen( uploader = d.uploader, thumbnail = d.thumbnail, resolved = r, + uploaderUrl = d.uploaderUrl, ) } val params = PictureInPictureParams.Builder() @@ -746,6 +748,7 @@ private fun InlinePlayer( uploader = uploader, thumbnail = thumbnail, resolved = r, + uploaderUrl = state.detail?.uploaderUrl, ) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt index 446729753..3732b9212 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -24,6 +24,14 @@ data class NowPlayingItem( val streamUrl: String, val title: String, val uploader: String, + /** + * Uploader's channel URL — needed by the autoplay path so the + * end-of-video handler can call channelInfo() to find the next + * same-channel candidate. Optional because some items come from + * paths where we don't have it (deep links, history rows on a + * cold start before strawcore has resolved metadata). + */ + val uploaderUrl: String? = null, val thumbnail: String?, val segments: List = emptyList(), ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 7af5a161f..7ddd1a85b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -45,8 +45,18 @@ import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.sulkta.straw.StrawActivity +import com.sulkta.straw.StrawApp +import com.sulkta.straw.data.AutoplayMode +import com.sulkta.straw.data.History +import com.sulkta.straw.data.Settings +import com.sulkta.straw.feature.detail.resolveStreamPlayback import com.sulkta.straw.net.IosSafeHttpDataSource import com.sulkta.straw.net.STRAW_USER_AGENT +import com.sulkta.straw.net.SponsorBlockClient +import com.sulkta.straw.util.runCatchingCancellable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @UnstableApi class PlaybackService : MediaSessionService() { @@ -105,16 +115,144 @@ class PlaybackService : MediaSessionService() { // and push it into NowPlaying so the minibar + SponsorBlock // skip-loop reflect the new track. claim() handles concurrent // setPlayingFrom races — see vc=35 audit HIGH-C6. + // + // SponsorBlock for queued items: when a queued item's segments + // are empty (which they always are — enqueueNext/Last doesn't + // pre-fetch SB to avoid the network round-trip on every long- + // press), kick off a background fetch and re-claim with the + // freshened segments. NowPlaying.claim handles the + // "same-streamUrl with fresher metadata" case via its CAS. + // + // Autoplay at end-of-queue: when STATE_ENDED fires and there's + // no next item in the queue, consult Settings.autoplayMode and + // pick a candidate. SameChannel → call channelInfo on the + // current uploader, take the first un-watched (gated on + // autoplaySkipWatched). YtRelated → would re-call streamInfo + // and pick info.related[0] but strawcore returns empty for + // related today, so it's a no-op until that lands. player.addListener(object : Player.Listener { override fun onMediaItemTransition(item: MediaItem?, reason: Int) { if (item == null) return val idx = player.currentMediaItemIndex val queued = Queue.at(idx) ?: return NowPlaying.claim(queued) + if (queued.segments.isEmpty()) { + val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl) + if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId) + } + } + + override fun onPlaybackStateChanged(state: Int) { + if (state != Player.STATE_ENDED) return + val mode = Settings.get().autoplayMode.value + if (mode == AutoplayMode.Off) return + // Media3 auto-advances inside the queue; we only kick + // in when the queue has truly run out. mediaItemCount + // hits 0 after the engine reports STATE_ENDED in some + // edge cases — handle both. + val atEnd = player.mediaItemCount <= 1 || + player.currentMediaItemIndex >= player.mediaItemCount - 1 + if (!atEnd) return + tryAutoplay(mode) } }) } + private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) { + StrawApp.globalScope.launch { + runCatchingCancellable { + val cats = Settings.get().sbCategories.value.map { it.key } + if (cats.isEmpty()) return@runCatchingCancellable + val segments = withContext(Dispatchers.IO) { + SponsorBlockClient.fetch(videoId, cats) + } + if (segments.isNotEmpty()) { + NowPlaying.claim(item.copy(segments = segments)) + } + } + } + } + + private fun tryAutoplay(mode: AutoplayMode) { + val current = NowPlaying.current.value ?: return + val uploaderUrl = current.uploaderUrl + // We need the channel URL for the SameChannel path; YtRelated + // re-resolves the current video's info. If we don't have what + // we need, silently bail — better than a half-baked surprise. + val controller = (mediaSession?.player as? Player) ?: return + StrawApp.globalScope.launch { + runCatchingCancellable { + val candidateUrl = withContext(Dispatchers.IO) { + pickAutoplayCandidate(mode, current.streamUrl, uploaderUrl) + } ?: return@runCatchingCancellable + // Resolve + enqueue + auto-play. Because the queue is + // currently empty (we just ended), enqueueLast routes + // through setPlayingFrom (auto-starts). + val info = withContext(Dispatchers.IO) { + uniffi.strawcore.streamInfo(candidateUrl) + } + val resolved = resolveStreamPlayback(info) + withContext(Dispatchers.Main) { + controller.enqueueLast( + streamUrl = candidateUrl, + title = info.title, + uploader = info.uploader, + thumbnail = info.thumbnail, + resolved = resolved, + uploaderUrl = info.uploaderUrl, + ) + } + } + } + } + + private fun pickAutoplayCandidate( + mode: AutoplayMode, + currentStreamUrl: String, + uploaderUrl: String?, + ): String? = when (mode) { + AutoplayMode.Off -> null + AutoplayMode.SameChannel -> { + if (uploaderUrl.isNullOrBlank()) null + else runCatching { + val ch = uniffi.strawcore.channelInfo(uploaderUrl) + val watched = if (Settings.get().autoplaySkipWatched.value) { + History.get().watches.value.map { it.videoId }.toSet() + } else emptySet() + ch.videos + .asSequence() + .filter { it.url != currentStreamUrl } + .filter { + if (watched.isEmpty()) true + else { + val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url) + id == null || id !in watched + } + } + .firstOrNull()?.url + }.getOrNull() + } + AutoplayMode.YtRelated -> { + runCatching { + val info = uniffi.strawcore.streamInfo(currentStreamUrl) + val watched = if (Settings.get().autoplaySkipWatched.value) { + History.get().watches.value.map { it.videoId }.toSet() + } else emptySet() + info.related + .asSequence() + .filter { it.url != currentStreamUrl } + .filter { + if (watched.isEmpty()) true + else { + val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url) + id == null || id !in watched + } + } + .firstOrNull()?.url + }.getOrNull() + } + } + override fun onGetSession( controllerInfo: MediaSession.ControllerInfo, ): MediaSession? = mediaSession diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 4d455a47a..fb466eebc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -117,6 +117,7 @@ fun PlayerScreen( uploader = uploader, thumbnail = thumbnail, resolved = r, + uploaderUrl = detail?.uploaderUrl, ) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 127488aa8..1932f1726 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken @@ -74,19 +75,21 @@ fun rememberStrawController(): MediaController? { * MediaSource based on MIME + the EXTRA_AUDIO_URL bundle. */ @UnstableApi -fun MediaController.setPlayingFrom( +fun Player.setPlayingFrom( streamUrl: String, title: String, uploader: String, thumbnail: String?, resolved: ResolvedPlayback, startPositionMs: Long = 0L, + uploaderUrl: String? = null, ) { val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return val nowPlayingItem = NowPlayingItem( streamUrl = streamUrl, title = title, uploader = uploader, + uploaderUrl = uploaderUrl, thumbnail = thumbnail, segments = resolved.segments, ) @@ -116,31 +119,34 @@ fun MediaController.setPlayingFrom( * failure (no playable stream in `resolved`). */ @UnstableApi -fun MediaController.enqueueNext( +fun Player.enqueueNext( streamUrl: String, title: String, uploader: String, thumbnail: String?, resolved: ResolvedPlayback, -): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = true) + uploaderUrl: String? = null, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = true) /** Append to the back of the queue. Same idle-fallback as enqueueNext. */ @UnstableApi -fun MediaController.enqueueLast( +fun Player.enqueueLast( streamUrl: String, title: String, uploader: String, thumbnail: String?, resolved: ResolvedPlayback, -): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = false) + uploaderUrl: String? = null, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = false) @UnstableApi -private fun MediaController.enqueueInternal( +private fun Player.enqueueInternal( streamUrl: String, title: String, uploader: String, thumbnail: String?, resolved: ResolvedPlayback, + uploaderUrl: String?, asNext: Boolean, ): Boolean { val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false @@ -148,13 +154,14 @@ private fun MediaController.enqueueInternal( streamUrl = streamUrl, title = title, uploader = uploader, + uploaderUrl = uploaderUrl, thumbnail = thumbnail, segments = resolved.segments, ) // Empty queue — there's nothing to "enqueue" onto. Treat as a // start-playing-now and route through the normal claim path. if (mediaItemCount == 0) { - setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved) + setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl = uploaderUrl) return true } val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 332e5b2f3..8d6a75551 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -44,6 +44,7 @@ import android.widget.Toast import androidx.lifecycle.viewmodel.compose.viewModel import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History +import com.sulkta.straw.data.AutoplayMode import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.SbCategory import com.sulkta.straw.data.SearchCache @@ -176,6 +177,72 @@ fun SettingsScreen() { HorizontalDivider() } + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Autoplay", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "When a video ends with nothing left in the queue, what should " + + "play next? Queue auto-advance always works regardless of " + + "this setting — autoplay only kicks in at the end.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + val autoplayMode by store.autoplayMode.collectAsState() + AutoplayMode.entries.forEach { m -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { store.setAutoplayMode(m) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (m == autoplayMode) "• ${m.label}" else " ${m.label}", + style = MaterialTheme.typography.bodyLarge, + color = if (m == autoplayMode) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + ) + } + Text( + m.help, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, bottom = 4.dp), + ) + HorizontalDivider() + } + Spacer(modifier = Modifier.height(12.dp)) + val skipWatched by store.autoplaySkipWatched.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Skip already-watched videos", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Autoplay picks the next un-watched video.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = skipWatched, + onCheckedChange = { store.setAutoplaySkipWatched(it) }, + ) + } + Spacer(modifier = Modifier.height(32.dp)) Text( "Local cache", From 62cc18c940c659f66fa4723579fe11874e0a807f Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 07:08:39 -0700 Subject: [PATCH 39/87] fixup vc=48: pickAutoplayCandidate uses try/catch (runCatching not suspend-aware) --- .../straw/feature/player/PlaybackService.kt | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 7ddd1a85b..e39194ee0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -206,50 +206,44 @@ class PlaybackService : MediaSessionService() { } } - private fun pickAutoplayCandidate( + private suspend fun pickAutoplayCandidate( mode: AutoplayMode, currentStreamUrl: String, uploaderUrl: String?, - ): String? = when (mode) { - AutoplayMode.Off -> null - AutoplayMode.SameChannel -> { - if (uploaderUrl.isNullOrBlank()) null - else runCatching { - val ch = uniffi.strawcore.channelInfo(uploaderUrl) - val watched = if (Settings.get().autoplaySkipWatched.value) { - History.get().watches.value.map { it.videoId }.toSet() - } else emptySet() - ch.videos - .asSequence() - .filter { it.url != currentStreamUrl } - .filter { - if (watched.isEmpty()) true - else { - val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url) - id == null || id !in watched - } - } - .firstOrNull()?.url - }.getOrNull() + ): String? { + val watched = if (Settings.get().autoplaySkipWatched.value) { + History.get().watches.value.map { it.videoId }.toSet() + } else emptySet() + fun unwatched(url: String): Boolean { + if (watched.isEmpty()) return true + val id = com.sulkta.straw.feature.detail.extractYtVideoId(url) + return id == null || id !in watched } - AutoplayMode.YtRelated -> { - runCatching { - val info = uniffi.strawcore.streamInfo(currentStreamUrl) - val watched = if (Settings.get().autoplaySkipWatched.value) { - History.get().watches.value.map { it.videoId }.toSet() - } else emptySet() - info.related - .asSequence() - .filter { it.url != currentStreamUrl } - .filter { - if (watched.isEmpty()) true - else { - val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url) - id == null || id !in watched - } - } - .firstOrNull()?.url - }.getOrNull() + return try { + when (mode) { + AutoplayMode.Off -> null + AutoplayMode.SameChannel -> { + if (uploaderUrl.isNullOrBlank()) return null + val ch = uniffi.strawcore.channelInfo(uploaderUrl) + ch.videos + .asSequence() + .filter { it.url != currentStreamUrl } + .filter { unwatched(it.url) } + .firstOrNull()?.url + } + AutoplayMode.YtRelated -> { + val info = uniffi.strawcore.streamInfo(currentStreamUrl) + info.related + .asSequence() + .filter { it.url != currentStreamUrl } + .filter { unwatched(it.url) } + .firstOrNull()?.url + } + } + } catch (c: kotlinx.coroutines.CancellationException) { + throw c + } catch (_: Throwable) { + null } } From 0f946d8b4ecf4bcda4d3869f86a9d3261ddc47da Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 07:17:29 -0700 Subject: [PATCH 40/87] vc=49: Auto-start playback setting (cold-open autoplay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Autoplay section now has a third toggle: Auto-start playback (default on). When on, opening a fresh video starts playing immediately on the detail page. When off, the page renders with the thumbnail + Play overlay and you tap to start. Independent of the end-of-queue autoplay mode and the back-from- fullscreen behavior (that already auto-resumes because the controller is mid-stream — preserved). Implementation: a single OR into the initial inlinePlaying state in VideoDetailScreen. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +-- .../com/sulkta/straw/data/SettingsStore.kt | 21 +++++++++++++++ .../straw/feature/detail/VideoDetailScreen.kt | 21 ++++++++------- .../straw/feature/settings/SettingsScreen.kt | 26 +++++++++++++++++++ 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index a155f1f75..937c3b1c7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 48 -const val STRAW_VERSION_NAME = "0.1.0-BH" +const val STRAW_VERSION_CODE = 49 +const val STRAW_VERSION_NAME = "0.1.0-BI" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 2580f8128..4a1c79780 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -65,6 +65,7 @@ private const val KEY_THEME = "theme_mode_v1" private const val KEY_CACHE_ENABLED = "cache_enabled_v1" private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1" private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" +private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -89,6 +90,19 @@ class SettingsStore(context: Context) { ) val autoplaySkipWatched: StateFlow = _autoplaySkipWatched.asStateFlow() + /** + * "Open a video → it starts playing immediately." Default on — + * matches YT/NewPipe. When off, opening a fresh video lands you + * on the detail page with the thumbnail + Play overlay; you tap + * to start. Doesn't affect back-from-fullscreen (that's a + * separate path in VideoDetailScreen that defaults to true when + * the shared controller is already streaming the URL). + */ + private val _autoStartPlayback = MutableStateFlow( + sp.getBoolean(KEY_AUTOSTART_PLAYBACK, true), + ) + val autoStartPlayback: StateFlow = _autoStartPlayback.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -137,6 +151,13 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply() } + fun setAutoStartPlayback(autoStart: Boolean) { + val before = _autoStartPlayback.value + if (before == autoStart) return + _autoStartPlayback.value = autoStart + sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index fb2f9aaef..cdcd0bbfe 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -133,16 +133,19 @@ fun VideoDetailScreen( ) } // Inline-play state resets when navigating to a different video. - // BUT: if the shared MediaController is already playing this exact - // stream — most commonly because the user popped back from - // fullscreen Player — default to true so the inline surface picks - // up the running playback instead of dropping back to the - // thumbnail+Play placeholder. Without this, system back from - // fullscreen looked like "the video went to background" — audio - // continued via the persistent controller but the video page - // re-rendered as a freshly-loaded detail. + // Defaults to TRUE when: + // * the shared MediaController is already streaming this URL + // (back-from-fullscreen — without this the page renders as + // "freshly loaded" while audio keeps playing in the + // background), or + // * the user has Settings → Auto-start playback enabled (cold + // open from search / subs / wherever immediately plays). + // Off + fresh URL → thumbnail + Play overlay, user taps to start. + val autoStart by Settings.get().autoStartPlayback.collectAsState() var inlinePlaying by remember(streamUrl) { - mutableStateOf(NowPlaying.current.value?.streamUrl == streamUrl) + mutableStateOf( + NowPlaying.current.value?.streamUrl == streamUrl || autoStart, + ) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 8d6a75551..a97af48b4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -242,6 +242,32 @@ fun SettingsScreen() { onCheckedChange = { store.setAutoplaySkipWatched(it) }, ) } + val autoStartPlayback by store.autoStartPlayback.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Auto-start playback", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Open a video → it starts immediately. Off: tap " + + "the thumbnail to start.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoStartPlayback, + onCheckedChange = { store.setAutoStartPlayback(it) }, + ) + } Spacer(modifier = Modifier.height(32.dp)) Text( From 714a2f8a929f3ccf95401e74a9d150d2e6d01847 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 07:21:09 -0700 Subject: [PATCH 41/87] fixup vc=49: missing Settings import in VideoDetailScreen --- .../kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index cdcd0bbfe..3dd48fb88 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -103,6 +103,7 @@ import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.LogDump import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews From 208cdf63260139edd67b87b294eeb771b0681afa Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 07:36:33 -0700 Subject: [PATCH 42/87] vc=50: Settings respects nav-bar inset + minibar overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last rows of Settings were rendering under the 3-button nav and under the floating minibar. Now: * Column gets .navigationBarsPadding() so it clears the system nav bar / gesture bar at the bottom. * Reactive minibarReserve (72dp when NowPlaying.current != null, else 0dp) added as a tail Spacer to clear the 64dp BottomCenter minibar chip. Only consumed when something's actually playing — no wasted space otherwise. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../straw/feature/settings/SettingsScreen.kt | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 937c3b1c7..038626994 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 49 -const val STRAW_VERSION_NAME = "0.1.0-BI" +const val STRAW_VERSION_CODE = 50 +const val STRAW_VERSION_NAME = "0.1.0-BJ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index a97af48b4..b79b2dd5d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -16,7 +16,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding +import com.sulkta.straw.feature.player.NowPlaying import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -77,10 +79,18 @@ fun SettingsScreen() { } } + // Clear the gesture-bar / 3-button nav bar at the bottom and add + // extra room for the minibar overlay when something's playing — + // otherwise the bottom rows of Settings render UNDER both. The + // minibar is a process-wide BottomCenter overlay (StrawActivity + // ScreenContent) so each scrolling screen has to leave its own gap. + val showingMinibar by NowPlaying.current.collectAsState() + val minibarReserve = if (showingMinibar != null) 72.dp else 0.dp Column( modifier = Modifier .fillMaxSize() .statusBarsPadding() + .navigationBarsPadding() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp, vertical = 16.dp), ) { @@ -415,6 +425,11 @@ fun SettingsScreen() { Text("Pick export file…") } } + + // Tail spacer to clear the minibar overlay when something's + // playing. Without this the last Settings row gets eaten by + // the 64dp BottomCenter chip. + Spacer(modifier = Modifier.height(minibarReserve)) } importResult?.let { res -> From dc1fff00db0ccfd0375b91159abfd652a81cac68 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 08:14:16 -0700 Subject: [PATCH 43/87] vc=51: bottom-clear sweep + DASH res cap + headphone pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three landing together. Bottom-clear sweep: Channel + Search + Subs feed + History + Playlists + Downloads all had content rendering under the system nav bar and the minibar overlay. Added a shared `util/BottomInsets.kt` `rememberBottomContentPadding()` that combines nav-bar inset + reactive 72dp minibar reserve (zero when nothing's playing). Plumbed into every LazyColumn's contentPadding. Same pattern Settings vc=50 used, lifted into a reusable helper. DASH/HLS max-resolution cap: Round-7 audit MED-3 — the user's max-resolution preference only affected the videoOnly/combined picker. DASH manifests bypassed the cap because Media3's ABR picked variants freely. New Player.applyMaxResolutionCap() pushes TrackSelectionParameters .setMaxVideoSize(MAX, ceiling) into the controller before prepare(). Auto = MAX_VALUE = unconstrained. Mid-stream setting changes take effect on next video. Pause on headphone disconnect: User-reported bug — wired headphones died, player switched to phone speaker instead of pausing. ExoPlayer's setHandleAudioBecomingNoisy honors Android's AUDIO_BECOMING_NOISY broadcast and pauses on the standard "headphones pulled" event. Wired into PlaybackService at construction + StrawApp.globalScope collector so flipping the setting mid-session takes effect on the already-built ExoPlayer. New Settings → Pause on headphone disconnect toggle, default on (matches every other Android media app's UX). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 8 +++- .../com/sulkta/straw/data/SettingsStore.kt | 20 +++++++++ .../straw/feature/channel/ChannelScreen.kt | 6 ++- .../straw/feature/download/DownloadsScreen.kt | 3 +- .../straw/feature/player/PlaybackService.kt | 22 +++++++++ .../feature/player/StrawMediaController.kt | 25 +++++++++++ .../straw/feature/playlist/PlaylistsScreen.kt | 5 ++- .../straw/feature/search/SearchScreen.kt | 6 ++- .../straw/feature/settings/SettingsScreen.kt | 26 +++++++++++ .../com/sulkta/straw/util/BottomInsets.kt | 45 +++++++++++++++++++ 11 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 038626994..73ed00783 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 50 -const val STRAW_VERSION_NAME = "0.1.0-BJ" +const val STRAW_VERSION_CODE = 51 +const val STRAW_VERSION_NAME = "0.1.0-BK" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 2170bb823..251fcceda 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -83,6 +83,7 @@ import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews @@ -258,7 +259,7 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(watches) { w -> RecentRow( item = w, @@ -436,7 +437,10 @@ private fun SubsPane( } } } - LazyColumn(state = listState) { + LazyColumn( + state = listState, + contentPadding = rememberBottomContentPadding(), + ) { items(displayed) { item -> FeedRow( item = item, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 4a1c79780..05dbafae9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -66,6 +66,7 @@ private const val KEY_CACHE_ENABLED = "cache_enabled_v1" private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1" private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1" +private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -103,6 +104,18 @@ class SettingsStore(context: Context) { ) val autoStartPlayback: StateFlow = _autoStartPlayback.asStateFlow() + /** + * Honor Android's AUDIO_BECOMING_NOISY broadcast — wired headphones + * yanked / Bluetooth disconnect → pause instead of switching to the + * phone speaker. Default on; matches every other Android media app. + * Off lets playback follow the audio focus default (phone speaker + * takes over). + */ + private val _pauseOnHeadphoneDisconnect = MutableStateFlow( + sp.getBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, true), + ) + val pauseOnHeadphoneDisconnect: StateFlow = _pauseOnHeadphoneDisconnect.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -158,6 +171,13 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply() } + fun setPauseOnHeadphoneDisconnect(pause: Boolean) { + val before = _pauseOnHeadphoneDisconnect.value + if (before == pause) return + _pauseOnHeadphoneDisconnect.value = pause + sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 54e41dd0f..6333612d4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -55,6 +55,7 @@ import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount +import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.util.formatDuration @Composable @@ -86,7 +87,10 @@ fun ChannelScreen( Text("error: ${state.error}", color = MaterialTheme.colorScheme.error) } - else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) { + else -> LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = rememberBottomContentPadding(), + ) { item { state.banner?.let { b -> AsyncImage( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt index 6689b0508..5a2f6d2f3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.sulkta.straw.util.rememberBottomContentPadding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -134,7 +135,7 @@ fun DownloadsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(rows, key = { it.id }) { row -> DownloadRowView(row, context, onRemove = { runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index e39194ee0..3cae0426a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -55,6 +55,8 @@ import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.util.runCatchingCancellable import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,6 +64,7 @@ import kotlinx.coroutines.withContext class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null + private var settingsWatcherJob: Job? = null override fun onCreate() { super.onCreate() @@ -87,6 +90,12 @@ class PlaybackService : MediaSessionService() { .build(), /* handleAudioFocus = */ true, ) + // Honor the user's pause-on-headphone-disconnect preference + // at construction time. The Settings flow is also watched + // below so flipping it mid-session takes effect immediately. + .setHandleAudioBecomingNoisy( + Settings.get().pauseOnHeadphoneDisconnect.value, + ) .build() // Service shutdown is driven by onTaskRemoved (user swiped app away) @@ -109,6 +118,17 @@ class PlaybackService : MediaSessionService() { .setSessionActivity(sessionActivityIntent) .build() + // Watch the pause-on-headphone-disconnect setting so flipping + // it in Settings takes effect on this already-built ExoPlayer + // without requiring a service restart. The initial value was + // baked in via the builder above — this picks up subsequent + // flips. + settingsWatcherJob = StrawApp.globalScope.launch { + Settings.get().pauseOnHeadphoneDisconnect.collect { handle -> + player.setHandleAudioBecomingNoisy(handle) + } + } + // Queue auto-advance bridge: when Media3 transitions to the // next item in the queue, look up the matching NowPlayingItem // (with original streamUrl, uploader, thumbnail, SB segments) @@ -267,6 +287,8 @@ class PlaybackService : MediaSessionService() { } override fun onDestroy() { + settingsWatcherJob?.cancel() + settingsWatcherJob = null // Null the field first so a late onGetSession during teardown gets // null rather than a released session. val s = mediaSession diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 1932f1726..9edb2a3ab 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -38,10 +38,12 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors +import com.sulkta.straw.data.Settings import com.sulkta.straw.feature.detail.ResolvedPlayback val LocalStrawController = compositionLocalOf { null } @@ -104,11 +106,34 @@ fun Player.setPlayingFrom( // long-press-enqueues more items, append/insertAt keep them // synced. Queue.setAll(nowPlayingItem) + // Apply the user's max-resolution cap to DASH/HLS adaptive + // streams. Round-7 audit MED-3 — the cap previously only + // affected the videoOnly/combined picker; DASH manifests + // bypassed it because Media3 picked variants freely. setMaxVideoSize + // tells the ABR algorithm to never pick anything taller than + // ceiling. Auto = Int.MAX_VALUE = no constraint. + applyMaxResolutionCap() setMediaItem(mediaItem, startPositionMs) prepare() playWhenReady = true } +/** + * Push the current Settings.maxResolution into the controller's + * TrackSelectionParameters as a height cap. Idempotent — safe to + * call repeatedly. Called inside setPlayingFrom so every new + * playback respects the live preference; setting changes mid-stream + * apply on next video. + */ +@UnstableApi +fun Player.applyMaxResolutionCap() { + val ceiling = Settings.get().maxResolution.value.ceiling + val maxHeight = if (ceiling >= Int.MAX_VALUE) Int.MAX_VALUE else ceiling + trackSelectionParameters = trackSelectionParameters.buildUpon() + .setMaxVideoSize(Int.MAX_VALUE, maxHeight) + .build() +} + /** * Add a video to the playback queue right after the currently-playing * item. If the player is idle (no current item), fall through to a diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt index 44c1ad36f..a512c6565 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.sulkta.straw.data.Playlists +import com.sulkta.straw.util.rememberBottomContentPadding @Composable fun PlaylistsScreen( @@ -87,7 +88,7 @@ fun PlaylistsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(playlists, key = { it.id }) { pl -> Row( modifier = Modifier @@ -221,7 +222,7 @@ fun PlaylistViewScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(playlist.items, key = { it.streamUrl }) { item -> Row( modifier = Modifier diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 9dfed4063..c4e23d6a1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews +import com.sulkta.straw.util.rememberBottomContentPadding @Composable fun SearchScreen( @@ -160,7 +161,10 @@ fun SearchScreen( modifier = Modifier.padding(bottom = 4.dp), ) } - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberBottomContentPadding(), + ) { items(state.results) { item -> ResultRow( item = item, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index b79b2dd5d..784baa887 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -278,6 +278,32 @@ fun SettingsScreen() { onCheckedChange = { store.setAutoStartPlayback(it) }, ) } + val pauseOnHeadphones by store.pauseOnHeadphoneDisconnect.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Pause on headphone disconnect", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Wired pull / Bluetooth drop → pause instead of " + + "switching to the phone speaker.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = pauseOnHeadphones, + onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) }, + ) + } Spacer(modifier = Modifier.height(32.dp)) Text( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt new file mode 100644 index 000000000..b814a98a2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Shared bottom-padding helper for every scrolling screen. + * + * Two things float over the bottom of the activity-level layout and + * need to be cleared: + * 1. The system navigation bar (3-button or gesture). Insets via + * WindowInsets.navigationBars. + * 2. The Straw minibar overlay. Reactive — only present when + * NowPlaying.current is non-null. ~64dp tall + a small gap → + * 72dp reserve. + * + * LazyColumn-based screens plumb this into `contentPadding` so items + * scroll PAST the bottom without being eaten. verticalScroll columns + * append a tail Spacer of the same height. + */ + +package com.sulkta.straw.util + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sulkta.straw.feature.player.NowPlaying + +/** Combined bottom Dp: nav-bar inset + 72dp when minibar's visible. */ +@Composable +fun rememberBottomBarReserveDp(): Dp { + val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val item by NowPlaying.current.collectAsStateWithLifecycle() + val minibar = if (item != null) 72.dp else 0.dp + return navBottom + minibar +} + +/** Convenience for LazyColumn.contentPadding — adds nothing on the top/start/end. */ +@Composable +fun rememberBottomContentPadding(): PaddingValues = + PaddingValues(bottom = rememberBottomBarReserveDp()) From ebe1fc846472e9ce35b59bcdc4096bc3973ea307 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 08:43:06 -0700 Subject: [PATCH 44/87] vc=52: R8 enabled + surface-handoff polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8 (minify + resource-shrink) flipped on for BOTH debug AND release variants — we publish the debug APK to fdroid (per existing pipeline), and the audit-flagged Log.d strip discipline required R8 to actually run on the variant we ship. New strawApp/proguard-rules.pro covers: * UniFFI bindings (uniffi.strawcore.*) — reflective FFI dispatch from Rust side, must survive minification * JNA — Library subclasses reflectively loaded by name * kotlinx-serialization @Serializable — generated $$serializer companions, kept via both the package-anchored rule and the annotation-wildcard rule for belt + suspenders * Media3 session Parcelables (cross-process via Binder) * Compose runtime + Strawcore exception hierarchy Surface-handoff polish on inline ↔ fullscreen transitions: setKeepContentOnPlayerReset(true) on both PlayerViews (inline in VideoDetail + fullscreen Player). When the detaching view's player is nulled on dispose, it holds the last rendered frame instead of flashing black. The receiving view's surface takeover then renders the next frame without the ~1-frame black gap. Round-4 audit HIGH-5 was the closest writeup. Expected APK-size win from R8: ~30-40%. Need real-device verification post-install — the keep rules are best-effort and a missing rule manifests as runtime ClassNotFoundException or silently-broken kotlinx-serialization decoding. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/build.gradle.kts | 19 ++++- strawApp/proguard-rules.pro | 81 +++++++++++++++++++ .../straw/feature/detail/VideoDetailScreen.kt | 6 ++ .../straw/feature/player/PlayerScreen.kt | 8 ++ 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 strawApp/proguard-rules.pro diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 73ed00783..9eff49449 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 51 -const val STRAW_VERSION_NAME = "0.1.0-BK" +const val STRAW_VERSION_CODE = 52 +const val STRAW_VERSION_NAME = "0.1.0-BL" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index bb98352e7..b62f16540 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -39,13 +39,30 @@ configure { } buildTypes { + // R8 enabled on BOTH variants — we publish the debug APK to + // fdroid (com.sulkta.straw.debug) per the existing pipeline, + // and audit-flagged Log.d strips depended on R8 actually + // running on the variant we ship. Keep rules in + // strawApp/proguard-rules.pro cover UniFFI + JNA + + // kotlinx-serialization companions. debug { isDebuggable = true applicationIdSuffix = ".debug" resValue("string", "app_name", "Straw debug") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro new file mode 100644 index 000000000..2a81fe15c --- /dev/null +++ b/strawApp/proguard-rules.pro @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 Sulkta-Coop +# SPDX-License-Identifier: GPL-3.0-or-later +# +# R8 keep rules for the Straw app module. The legacy `app/proguard-rules.pro` +# is for the upstream NewPipe module — different namespaces, different +# rules. This file is OURS. +# +# AGP's getDefaultProguardFile("proguard-android-optimize.txt") handles +# the Android framework + AndroidX + Compose runtime defaults via +# consumer rules shipped with each library. We only need to spell out +# what those defaults can't see: +# +# * UniFFI bindings — reflective FFI dispatch from generated code. +# * JNA — reflects on every class extending com.sun.jna.Library +# (that's how the loadLibrary glue works). +# * Our kotlinx-serialization @Serializable types — their generated +# $$serializer companions get tree-shaken without explicit keeps. +# * Media3 session metadata Parcelables. + +# -- UniFFI ------------------------------------------------------------- +# Generated bindings live under uniffi.strawcore.*. The Rust side calls +# them via JNI symbol name; if R8 renames the class or methods, every +# extractor call NPEs. +-keep class uniffi.strawcore.** { *; } +-keep class uniffi.** { *; } + +# -- JNA --------------------------------------------------------------- +# JNA looks up Library subclasses by Class.forName + reflection at +# load time. Anything that extends Library or has @FieldOrder must +# survive. +-keep class * extends com.sun.jna.Library { *; } +-keep class com.sun.jna.** { *; } +-dontwarn com.sun.jna.** + +# -- kotlinx-serialization --------------------------------------------- +# Every @Serializable type gets a synthetic Companion + $$serializer +# class. R8 will strip the $$serializer if nothing visibly calls it +# (the lookup goes through reflection on the Companion). +-keepattributes *Annotation*, InnerClasses +-dontwarn kotlinx.serialization.** + +-keep,includedescriptorclasses class com.sulkta.straw.**$$serializer { *; } +-keepclassmembers class com.sulkta.straw.** { + *** Companion; +} +-keepclasseswithmembers class com.sulkta.straw.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Same dance for our top-level @Serializable types defined outside +# `com.sulkta.straw.**` (Rust DTOs, etc.). Belt + suspenders. +-keepclassmembers @kotlinx.serialization.Serializable class * { + static **$Companion Companion; + public static <1>$Companion Companion; +} +-keepclasseswithmembers @kotlinx.serialization.Serializable class * { + kotlinx.serialization.KSerializer serializer(...); +} +-keep class **$$serializer { *; } + +# -- Media3 / ExoPlayer ------------------------------------------------ +# Most of Media3 ships consumer rules but session-related Parcelables +# are reflectively reconstructed across process boundaries (the +# MediaController talks to PlaybackService via Binder). Keep their +# field names. +-keep class androidx.media3.session.** { *; } +-keep class androidx.media3.common.MediaItem { *; } +-keep class androidx.media3.common.MediaItem$* { *; } +-keep class androidx.media3.common.MediaMetadata { *; } + +# -- Strawcore exceptions / DTOs reflected by UniFFI -------------------- +# StrawcoreError is a sealed Throwable hierarchy exposed via UniFFI. +# Keep all subclasses + their fields so the Kotlin pattern-match works +# after minification. +-keep class com.sulkta.straw.feature.player.** { *; } + +# -- Reflection-via-Class.forName paths from Compose -------------------- +# Compose's runtime does some Class.forName for its own bootstrap; the +# AGP consumer rules cover this, but documenting the dependency here +# so a future bump doesn't surprise us. +-keep class androidx.compose.runtime.** { *; } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 3dd48fb88..04d79017d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -799,6 +799,12 @@ private fun InlinePlayer( PlayerView(ctx).apply { player = controller useController = true + // Same surface-handoff polish as the + // fullscreen PlayerView — hold the last + // frame on dispose so the inline ↔ + // fullscreen transition doesn't flash + // black between detach + reattach. + setKeepContentOnPlayerReset(true) } }, update = { it.player = controller }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index fb466eebc..02479cf99 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -178,6 +178,14 @@ fun PlayerScreen( PlayerView(ctx).apply { player = controller useController = true + // Keep the last frame on screen when this + // view's player is reset (fullscreen → + // inline transition). Without this, the + // detaching PlayerView flashes black for + // ~1 frame before the receiving view takes + // over the surface. + controllerHideOnTouch = true + setKeepContentOnPlayerReset(true) } }, update = { it.player = controller }, From e26a3eca19273f972539f2ad8e3ed6c30c4ddaae Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:04:50 -0700 Subject: [PATCH 45/87] vc=53: scrub-point store + auto-resume on video open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResumePositionsStore — SharedPreferences-lite, JSON-blob keyed by videoId. Caps at 500 entries, prunes oldest on add. Skips trivial positions (< 5s) and clears near-end (within 5s of duration) so a finished video doesn't auto-resume to its credits. PlaybackService — 5s polling job + onIsPlayingChanged(false) + onDestroy capture write player position via NowPlaying.streamUrl → videoId. Runs on Main so the player read is thread-safe; store write is SP.apply() async. setPlayingFrom — when caller passes startPositionMs=0L AND Settings.autoResume is on, lookup the saved position and use it. Surface-handoff path (inline ↔ fullscreen) is untouched — MediaController already holds its own position across surfaces. This only fires on fresh opens (cold start, app update, video re-tap from history). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../sulkta/straw/data/ResumePositionsStore.kt | 146 ++++++++++++++++++ .../com/sulkta/straw/data/SettingsStore.kt | 20 +++ .../straw/feature/player/PlaybackService.kt | 48 ++++++ .../feature/player/StrawMediaController.kt | 16 +- .../straw/feature/settings/SettingsScreen.kt | 26 ++++ 7 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9eff49449..3bd5dd502 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 52 -const val STRAW_VERSION_NAME = "0.1.0-BL" +const val STRAW_VERSION_CODE = 53 +const val STRAW_VERSION_NAME = "0.1.0-BM" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 2868d58ac..58d75fa9f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -9,6 +9,7 @@ import android.app.Application import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.Playlists +import com.sulkta.straw.data.Resume import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions @@ -67,6 +68,7 @@ class StrawApp : Application() { History.init(this) Subscriptions.init(this) Playlists.init(this) + Resume.init(this) // vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache // (~150 KB) JSON-decode at construction. Stash the // applicationContext eagerly (cheap) so `get()` is callable diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt new file mode 100644 index 000000000..3b4fa5f57 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Per-video scrub-point store. App update / process death / device + * reboot — all three would otherwise lose the user's place in a long + * video. We write position every ~5s while playing + on every pause + + * on player teardown, keyed by videoId so resume works across stream + * URL rotations (googlevideo URLs rotate per session). + * + * SharedPreferences-lite, single JSON blob, capped at MAX_RESUMES with + * oldest-eviction. Same shape as HistoryStore — graduates to Room if a + * real query pattern shows up. + */ + +package com.sulkta.straw.data + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class ResumePosition( + val positionMs: Long, + val durationMs: Long, + val lastWatchedAt: Long, +) + +private const val PREFS = "straw_resume_positions" +private const val KEY_POSITIONS = "positions_v1" + +/** Cap on retained per-video resume entries — prune oldest on overflow. */ +private const val MAX_RESUMES = 500 + +/** + * Skip writes for trivial positions — auto-resuming from 0:03 is more + * annoying than starting fresh. Mirrors YouTube's "near the start" + * threshold. + */ +private const val MIN_POSITION_MS = 5_000L + +/** + * When position is within END_THRESHOLD of duration, treat the video as + * "done" and clear the entry instead of recording. Otherwise a finished + * watch would auto-resume to the credits next time. + */ +private const val END_THRESHOLD_MS = 5_000L + +class ResumePositionsStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + private val _positions = MutableStateFlow(load()) + val positions: StateFlow> = _positions.asStateFlow() + + /** + * Record (or update) the scrub-point for a video. Skipped silently + * when: + * - videoId is blank + * - durationMs <= 0 (live stream / unknown) + * - positionMs is below MIN_POSITION_MS (just started) + * + * When positionMs is within END_THRESHOLD_MS of the end the entry is + * REMOVED so a finished video doesn't auto-resume to its credits. + */ + fun record(videoId: String, positionMs: Long, durationMs: Long) { + if (videoId.isBlank()) return + if (durationMs <= 0L) return + if (positionMs < MIN_POSITION_MS) return + if (positionMs >= durationMs - END_THRESHOLD_MS) { + clearOne(videoId) + return + } + val entry = ResumePosition( + positionMs = positionMs, + durationMs = durationMs, + lastWatchedAt = System.currentTimeMillis(), + ) + val before = _positions.value + val next = _positions.updateAndGet { current -> + val withEntry = current + (videoId to entry) + if (withEntry.size > MAX_RESUMES) { + // Drop oldest by lastWatchedAt — newcomers always land + // because the entry we just added is by definition the + // freshest. take(MAX_RESUMES) of the sorted-desc list. + withEntry.entries + .sortedByDescending { it.value.lastWatchedAt } + .take(MAX_RESUMES) + .associate { it.key to it.value } + } else { + withEntry + } + } + if (next !== before) { + sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + } + } + + /** Returns null when the video has no recorded position. */ + fun get(videoId: String): ResumePosition? { + if (videoId.isBlank()) return null + return _positions.value[videoId] + } + + fun clearOne(videoId: String) { + if (videoId.isBlank()) return + val before = _positions.value + val next = _positions.updateAndGet { current -> + if (videoId !in current) current else current - videoId + } + if (next !== before) { + sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + } + } + + fun clearAll() { + _positions.updateAndGet { emptyMap() } + sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap())).apply() + } + + private fun load(): Map = runCatching { + val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap() + json.decodeFromString>(s) + }.getOrDefault(emptyMap()) +} + +/** App-wide singleton; created in StrawApp.onCreate. */ +object Resume { + @Volatile private var instance: ResumePositionsStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = ResumePositionsStore(context.applicationContext) + } + } + } + + fun get(): ResumePositionsStore = instance + ?: error("ResumePositionsStore not initialized — call Resume.init(context)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 05dbafae9..7ccab0543 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -67,6 +67,7 @@ private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1" private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1" private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1" +private const val KEY_AUTO_RESUME = "auto_resume_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -116,6 +117,18 @@ class SettingsStore(context: Context) { ) val pauseOnHeadphoneDisconnect: StateFlow = _pauseOnHeadphoneDisconnect.asStateFlow() + /** + * Auto-resume scrub-point on video open. When on (default), opening + * a video that has a saved position picks up where the user left + * off. When off, every open starts at 0:00. Doesn't affect inline- + * ↔ fullscreen hand-off (the shared MediaController keeps its own + * position across surfaces; this only matters on fresh opens). + */ + private val _autoResume = MutableStateFlow( + sp.getBoolean(KEY_AUTO_RESUME, true), + ) + val autoResume: StateFlow = _autoResume.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -178,6 +191,13 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply() } + fun setAutoResume(enabled: Boolean) { + val before = _autoResume.value + if (before == enabled) return + _autoResume.value = enabled + sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 3cae0426a..692aebd04 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -48,6 +48,7 @@ import com.sulkta.straw.StrawActivity import com.sulkta.straw.StrawApp import com.sulkta.straw.data.AutoplayMode import com.sulkta.straw.data.History +import com.sulkta.straw.data.Resume import com.sulkta.straw.data.Settings import com.sulkta.straw.feature.detail.resolveStreamPlayback import com.sulkta.straw.net.IosSafeHttpDataSource @@ -56,7 +57,9 @@ import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.util.runCatchingCancellable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -65,6 +68,7 @@ class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null private var settingsWatcherJob: Job? = null + private var resumePollJob: Job? = null override fun onCreate() { super.onCreate() @@ -162,6 +166,13 @@ class PlaybackService : MediaSessionService() { } } + override fun onIsPlayingChanged(isPlaying: Boolean) { + // Capture on every play→pause edge. Covers user taps, + // audio focus loss, headphone-noisy pause. The 5s poll + // covers the play-through case. + if (!isPlaying) captureResumePosition(player) + } + override fun onPlaybackStateChanged(state: Int) { if (state != Player.STATE_ENDED) return val mode = Settings.get().autoplayMode.value @@ -176,6 +187,34 @@ class PlaybackService : MediaSessionService() { tryAutoplay(mode) } }) + + // Periodic scrub-point write. Stays on Main so player reads are + // thread-safe; the SP write inside record() is async (apply()). + // 5s cadence is the sweet spot — finer is wasted disk churn, + // coarser loses too much on a sudden process death. + resumePollJob = StrawApp.globalScope.launch(Dispatchers.Main) { + while (isActive) { + delay(RESUME_POLL_INTERVAL_MS) + captureResumePosition(player) + } + } + } + + /** + * Read the current player position and persist it to the + * ResumePositionsStore. Bails on idle/ended states and unknown + * durations (live streams). The store itself enforces minimum- + * position + near-end-clear thresholds. + */ + private fun captureResumePosition(player: Player) { + val state = player.playbackState + if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) return + val item = NowPlaying.current.value ?: return + val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return + val pos = player.currentPosition + val dur = player.duration + if (dur <= 0L) return + Resume.get().record(videoId, pos, dur) } private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) { @@ -287,6 +326,12 @@ class PlaybackService : MediaSessionService() { } override fun onDestroy() { + // Final scrub-point snapshot before teardown — covers swipe- + // away-without-pause case. Read before cancelling the poll + // job (the job's last tick may not have landed yet). + mediaSession?.player?.let { captureResumePosition(it) } + resumePollJob?.cancel() + resumePollJob = null settingsWatcherJob?.cancel() settingsWatcherJob = null // Null the field first so a late onGetSession during teardown gets @@ -307,6 +352,9 @@ class PlaybackService : MediaSessionService() { * MediaItem's video URI to produce a combined video+audio source. */ const val EXTRA_AUDIO_URL = "straw.audio_url" + + /** Scrub-point write cadence while the player is alive. */ + private const val RESUME_POLL_INTERVAL_MS = 5_000L } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 9edb2a3ab..923dcd095 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -43,8 +43,10 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors +import com.sulkta.straw.data.Resume import com.sulkta.straw.data.Settings import com.sulkta.straw.feature.detail.ResolvedPlayback +import com.sulkta.straw.feature.detail.extractYtVideoId val LocalStrawController = compositionLocalOf { null } @@ -113,7 +115,19 @@ fun Player.setPlayingFrom( // tells the ABR algorithm to never pick anything taller than // ceiling. Auto = Int.MAX_VALUE = no constraint. applyMaxResolutionCap() - setMediaItem(mediaItem, startPositionMs) + // Auto-resume: when the caller passed the default 0L and + // Settings.autoResume is on, look up the saved scrub-point for + // this videoId. Lets the user pick up where they left off after + // an app update / process death. The store skips trivial + // positions and clears near-end so we don't auto-resume to 0:03 + // or to the credits. + val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) { + val videoId = extractYtVideoId(streamUrl) + videoId?.let { Resume.get().get(it)?.positionMs } ?: 0L + } else { + startPositionMs + } + setMediaItem(mediaItem, effectiveStart) prepare() playWhenReady = true } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 784baa887..c8a11962a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -304,6 +304,32 @@ fun SettingsScreen() { onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) }, ) } + val autoResume by store.autoResume.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Resume where you left off", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Reopen a video → pick up at the saved scrub-point. " + + "Off: every open starts at 0:00.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoResume, + onCheckedChange = { store.setAutoResume(it) }, + ) + } Spacer(modifier = Modifier.height(32.dp)) Text( From 080346716b736df5a0434d7fd1a621b0797290ed Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:09:07 -0700 Subject: [PATCH 46/87] fixup vc=53: keep screen on while fullscreen + inline player attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb hit screen-timeout-while-watching mid-build. View-level keepScreenOn=true on both PlayerViews — propagates to the window while the view is attached, releases the wake-lock automatically when the user backs out. Same pattern Media3 recommends for video apps. --- .../com/sulkta/straw/feature/detail/VideoDetailScreen.kt | 5 +++++ .../com/sulkta/straw/feature/player/PlayerScreen.kt | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 04d79017d..6ae432336 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -805,6 +805,11 @@ private fun InlinePlayer( // fullscreen transition doesn't flash // black between detach + reattach. setKeepContentOnPlayerReset(true) + // Don't let the device timeout while the + // inline player is on-screen with the + // user reading the description. Detaches + // automatically when this view goes away. + keepScreenOn = true } }, update = { it.player = controller }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 02479cf99..e7b763148 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -186,6 +186,14 @@ fun PlayerScreen( // over the surface. controllerHideOnTouch = true setKeepContentOnPlayerReset(true) + // Don't let the device timeout/lock while + // a fullscreen video is on-screen. View- + // level flag — propagates to the window + // while attached, clears on detach so + // backing out of fullscreen releases the + // wake-lock automatically. Mirror on the + // inline PlayerView for consistency. + keepScreenOn = true } }, update = { it.player = controller }, From fbccdce65a11f8b33079a6148302ea5670a20919 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:28:04 -0700 Subject: [PATCH 47/87] vc=54: red progress-bar overlay on video thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YT/NewPipe-style — when ResumePositionsStore has an entry for a video, paint a 3dp red bar across the bottom of the thumbnail showing position/duration. Reads instantly as "you started this." Consolidated the duplicate thumbnail render logic across 6 row sites (FeedRow, RecentRow, ResultRow, ChannelVideoRow, RelatedRow, PlaylistsScreen) into a single feature/player/VideoThumbnail composable. Includes the existing duration-pill overlay + the new progress bar. ThumbnailProgressOverlay is a BoxScope extension so custom thumbnail compositions can drop it in without going through the full helper. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 51 ++------ .../kotlin/com/sulkta/straw/StrawTheme.kt | 6 + .../straw/feature/channel/ChannelScreen.kt | 11 +- .../straw/feature/detail/VideoDetailScreen.kt | 11 +- .../straw/feature/player/ThumbnailProgress.kt | 113 ++++++++++++++++++ .../straw/feature/playlist/PlaylistsScreen.kt | 11 +- .../straw/feature/search/SearchScreen.kt | 11 +- 8 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 3bd5dd502..e3e0e57f4 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 53 -const val STRAW_VERSION_NAME = "0.1.0-BM" +const val STRAW_VERSION_CODE = 54 +const val STRAW_VERSION_NAME = "0.1.0-BN" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 251fcceda..7510d452b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -80,12 +80,11 @@ import com.sulkta.straw.data.History import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.rememberBottomContentPadding -import com.sulkta.straw.OverlayDimColor -import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews import kotlinx.coroutines.launch @@ -508,8 +507,9 @@ private fun FeedRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - ThumbnailWithDuration( + VideoThumbnail( thumbnail = item.thumbnail, + videoUrl = item.url, durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) @@ -546,41 +546,6 @@ private fun FeedRow( } } -/** - * 16:9 thumbnail with a NewPipe-style duration pill burned into the - * bottom-right corner. `durationSeconds == 0` skips the badge (live - * streams, mixes that come back without a duration, etc.). - */ -@Composable -private fun ThumbnailWithDuration( - thumbnail: String?, - durationSeconds: Long, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier) { - AsyncImage( - model = thumbnail, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(6.dp)), - ) - if (durationSeconds > 0) { - Text( - text = formatDuration(durationSeconds), - style = MaterialTheme.typography.labelSmall, - color = androidx.compose.ui.graphics.Color.White, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .clip(RoundedCornerShape(3.dp)) - .background(OverlayDimColor) - .padding(horizontal = 4.dp, vertical = 1.dp), - ) - } - } -} - @Composable private fun SubChip( ch: ChannelRef, @@ -643,13 +608,13 @@ private fun RecentRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = 0L, modifier = Modifier .width(120.dp) - .height(68.dp) - .clip(RoundedCornerShape(6.dp)), + .height(68.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt index ebbacd377..85fa21b09 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -82,3 +82,9 @@ fun strawDarkColors(): ColorScheme = darkColorScheme( // minibar thumbnail. Kept here so a theme tweak touches one place. val OverlayChromeColor = Color(0xCC222222) val OverlayDimColor = Color(0xCC000000) + +// Watch-progress bar painted across the bottom of a video thumbnail when +// the user has a saved scrub-point. Solid red foreground over a slightly- +// dim track. Matches YT / NewPipe conventions so it reads instantly. +val ProgressBarFillColor = Color(0xFFE53935) +val ProgressBarTrackColor = Color(0x66000000) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 6333612d4..6ecd48a08 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.playlist.VideoActionTarget @@ -177,13 +178,13 @@ private fun ChannelVideoRow( .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 6ae432336..e10c841f9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -99,6 +99,7 @@ import com.sulkta.straw.feature.download.DownloadKind import com.sulkta.straw.feature.download.Downloader import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.NowPlaying +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.LogDump @@ -680,13 +681,13 @@ private fun RelatedRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt new file mode 100644 index 000000000..bc4bd4e4f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Red progress bar painted across the bottom of a video thumbnail when + * the user has a saved scrub-point in ResumePositionsStore. Same shape + * YouTube + NewPipe use — instantly readable as "you started this." + * + * Drops into any thumbnail-rendering Box; the caller is responsible for + * being inside a Box (so we can align to Bottom). Returns nothing when + * the videoId is blank or has no recorded position. + */ + +package com.sulkta.straw.feature.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.sulkta.straw.OverlayDimColor +import com.sulkta.straw.ProgressBarFillColor +import com.sulkta.straw.ProgressBarTrackColor +import com.sulkta.straw.data.Resume +import com.sulkta.straw.feature.detail.extractYtVideoId +import com.sulkta.straw.util.formatDuration + +/** + * Paint a 3dp watch-progress bar across the bottom of the surrounding + * Box when ResumePositionsStore has an entry for [videoId]. Silent + * no-op when there's no entry — safe to call unconditionally. + * + * Must be used inside a Box (uses BoxScope.align). Caller's Box sets + * the thumbnail size; this composable just overlays the bar. + */ +@Composable +fun BoxScope.ThumbnailProgressOverlay(videoId: String?) { + if (videoId.isNullOrBlank()) return + val positions by Resume.get().positions.collectAsStateWithLifecycle() + val entry = positions[videoId] ?: return + if (entry.durationMs <= 0L) return + val fraction = (entry.positionMs.toFloat() / entry.durationMs.toFloat()) + .coerceIn(0f, 1f) + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(3.dp) + .background(ProgressBarTrackColor), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(ProgressBarFillColor), + ) + } +} + +/** + * One-stop video thumbnail: 16:9 image with optional NewPipe-style + * duration pill at bottom-right + watch-progress overlay at bottom + * when the user has a saved scrub-point for [videoUrl]. + * + * Pass an outer modifier with the desired width/height; the corner + * radius + clip are applied inside so the progress bar bleeds to the + * exact edge of the rounded thumbnail. durationSeconds <= 0 drops the + * badge (live streams, items that come back without a duration). + */ +@Composable +fun VideoThumbnail( + thumbnail: String?, + videoUrl: String?, + durationSeconds: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.clip(RoundedCornerShape(6.dp))) { + AsyncImage( + model = thumbnail, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + if (durationSeconds > 0) { + Text( + text = formatDuration(durationSeconds), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(RoundedCornerShape(3.dp)) + .background(OverlayDimColor) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + ThumbnailProgressOverlay(videoUrl?.let { extractYtVideoId(it) }) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt index a512c6565..2a11d694a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.Playlists import com.sulkta.straw.util.rememberBottomContentPadding @@ -231,13 +232,13 @@ fun PlaylistViewScreen( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.streamUrl, + durationSeconds = 0L, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index c4e23d6a1..06075d32d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.History import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet @@ -202,13 +203,13 @@ private fun ResultRow( .padding(vertical = 10.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(160.dp) - .height(90.dp) - .clip(RoundedCornerShape(6.dp)), + .height(90.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { From ccd24c4ed37fb89446c3b627a907d8738e14e803 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:40:07 -0700 Subject: [PATCH 48/87] vc=55: in-app auto-updater polling fdroid.sulkta.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkManager periodic worker hits the repo's index-v2.json, parses the highest versionCode for our package, compares with BuildConfig.VERSION_CODE. When newer: posts a notification with ACTION_VIEW on the APK URL — Android's DownloadManager picks it up and the system installer takes over. No INSTALL_PACKAGES perm needed. Settings: - Check for updates toggle (default on — closing NewPipe's silent staleness gap is the explicit motivation) - Interval picker (1h / 6h / 24h, default 6h — WorkManager has a 15-min periodic floor anyway) - Last-checked timestamp + 'update available' tag when caught-up state is dirty - Check now button — runs the same path as the worker so behaviors stay identical Cold start fires one check too so users see pending updates without waiting a full interval. R8 keep-rule for UpdateCheckWorker added — WorkManager instantiates workers by name via reflection. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/build.gradle.kts | 5 + strawApp/proguard-rules.pro | 8 ++ .../main/kotlin/com/sulkta/straw/StrawApp.kt | 9 ++ .../com/sulkta/straw/data/SettingsStore.kt | 85 ++++++++++++++ .../straw/feature/settings/SettingsScreen.kt | 111 ++++++++++++++++++ .../straw/feature/update/AppUpdateClient.kt | 86 ++++++++++++++ .../straw/feature/update/UpdateCheckWorker.kt | 108 +++++++++++++++++ .../straw/feature/update/UpdateScheduler.kt | 63 ++++++++++ .../com/sulkta/straw/util/Formatting.kt | 16 +++ 10 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e3e0e57f4..b6068b94b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 54 -const val STRAW_VERSION_NAME = "0.1.0-BN" +const val STRAW_VERSION_CODE = 55 +const val STRAW_VERSION_NAME = "0.1.0-BO" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index b62f16540..e6458448e 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -130,6 +130,11 @@ dependencies { // Guava ListenableFuture support for awaiting MediaController connect. implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") + // WorkManager — periodic background poll of fdroid.sulkta.com index + // for self-update notifications. CoroutineWorker is built into the + // base work-runtime artifact as of 2.10. + implementation(libs.androidx.work.runtime) + // strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the // cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search / // streamInfo / channelInfo to replace NewPipeExtractor. diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro index 2a81fe15c..97c631f42 100644 --- a/strawApp/proguard-rules.pro +++ b/strawApp/proguard-rules.pro @@ -79,3 +79,11 @@ # AGP consumer rules cover this, but documenting the dependency here # so a future bump doesn't surprise us. -keep class androidx.compose.runtime.** { *; } + +# -- WorkManager Worker classes ---------------------------------------- +# WorkManager instantiates Worker subclasses by class name via +# reflection (`Class.forName(workerSpec.workerClassName)`). If R8 +# renames our UpdateCheckWorker the scheduler enqueues it but the +# instantiation fails silently and no checks ever run. +-keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; } +-keep class * extends androidx.work.ListenableWorker { *; } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 58d75fa9f..c3226623a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -14,6 +14,8 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.update.UpdateScheduler +import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.util.strawLogW import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -84,5 +86,12 @@ class StrawApp : Application() { appScope.launch { SettingsImport.sweepStale(this@StrawApp) } + // Auto-update polling. Schedule the periodic worker if enabled, + // then kick a fresh check on cold start so users don't wait a + // full interval to find out about a pending update. + UpdateScheduler.applyFromSettings(this) + if (Settings.get().autoUpdateCheck.value) { + appScope.launch { runUpdateCheck(this@StrawApp) } + } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 7ccab0543..208de8be5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -58,6 +58,17 @@ enum class AutoplayMode(val label: String, val help: String) { YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"), } +/** + * How often the auto-update worker polls fdroid.sulkta.com. WorkManager + * has a 15-minute floor on periodic work, so 1h is the tightest cadence + * we expose. + */ +enum class AutoUpdateInterval(val label: String) { + H1("Every hour"), + H6("Every 6 hours"), + H24("Every 24 hours"), +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_MAX_RES = "max_resolution_v1" @@ -68,6 +79,11 @@ private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1" private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1" private const val KEY_AUTO_RESUME = "auto_resume_v1" +private const val KEY_AUTO_UPDATE_CHECK = "auto_update_check_v1" +private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1" +private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1" +private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1" +private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -129,6 +145,40 @@ class SettingsStore(context: Context) { ) val autoResume: StateFlow = _autoResume.asStateFlow() + /** + * Periodic self-update check against fdroid.sulkta.com. Default on + * — NewPipe's "user forgets to update for 6 months" failure mode + * is the explicit thing we're closing. + */ + private val _autoUpdateCheck = MutableStateFlow( + sp.getBoolean(KEY_AUTO_UPDATE_CHECK, true), + ) + val autoUpdateCheck: StateFlow = _autoUpdateCheck.asStateFlow() + + private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval()) + val autoUpdateInterval: StateFlow = _autoUpdateInterval.asStateFlow() + + /** Last successful poll wall-clock ms; 0 if never. */ + private val _lastUpdateCheckMs = MutableStateFlow( + sp.getLong(KEY_LAST_UPDATE_CHECK_MS, 0L), + ) + val lastUpdateCheckMs: StateFlow = _lastUpdateCheckMs.asStateFlow() + + /** + * Cached "latest version seen on fdroid" — 0 / "" while none known + * or while caught-up. Lets SettingsScreen show "vc=55 available" + * without re-polling. + */ + private val _latestKnownVc = MutableStateFlow( + sp.getLong(KEY_LATEST_KNOWN_VC, 0L), + ) + val latestKnownVc: StateFlow = _latestKnownVc.asStateFlow() + + private val _latestKnownVname = MutableStateFlow( + sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "", + ) + val latestKnownVname: StateFlow = _latestKnownVname.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -198,6 +248,34 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply() } + fun setAutoUpdateCheck(enabled: Boolean) { + val before = _autoUpdateCheck.value + if (before == enabled) return + _autoUpdateCheck.value = enabled + sp.edit().putBoolean(KEY_AUTO_UPDATE_CHECK, enabled).apply() + } + + fun setAutoUpdateInterval(interval: AutoUpdateInterval) { + val before = _autoUpdateInterval.value + if (before == interval) return + _autoUpdateInterval.value = interval + sp.edit().putString(KEY_AUTO_UPDATE_INTERVAL, interval.name).apply() + } + + fun setLastUpdateCheck(ms: Long) { + _lastUpdateCheckMs.value = ms + sp.edit().putLong(KEY_LAST_UPDATE_CHECK_MS, ms).apply() + } + + fun setLatestKnownVersion(vc: Long, vname: String) { + _latestKnownVc.value = vc + _latestKnownVname.value = vname + sp.edit() + .putLong(KEY_LATEST_KNOWN_VC, vc) + .putString(KEY_LATEST_KNOWN_VNAME, vname) + .apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { @@ -225,6 +303,13 @@ class SettingsStore(context: Context) { val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel } + + private fun loadAutoUpdateInterval(): AutoUpdateInterval { + val name = sp.getString(KEY_AUTO_UPDATE_INTERVAL, null) + ?: return AutoUpdateInterval.H6 + return AutoUpdateInterval.entries.firstOrNull { it.name == name } + ?: AutoUpdateInterval.H6 + } } object Settings { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index c8a11962a..4fa6e7108 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -44,7 +44,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import android.widget.Toast import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.material3.FilterChip +import com.sulkta.straw.BuildConfig +import com.sulkta.straw.data.AutoUpdateInterval import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.feature.update.UpdateScheduler +import com.sulkta.straw.feature.update.runUpdateCheck +import com.sulkta.straw.util.formatRelativeSince import com.sulkta.straw.data.History import com.sulkta.straw.data.AutoplayMode import com.sulkta.straw.data.MaxResolution @@ -331,6 +337,111 @@ fun SettingsScreen() { ) } + Spacer(modifier = Modifier.height(32.dp)) + Text( + "App updates", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Polls fdroid.sulkta.com for newer Straw builds. When one's " + + "available, a notification taps through to the system " + + "installer. NewPipe's silent-staleness problem, solved.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + val autoUpdateCheck by store.autoUpdateCheck.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Check for updates", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Background poll. Tap the notification to install.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoUpdateCheck, + onCheckedChange = { checked -> + store.setAutoUpdateCheck(checked) + UpdateScheduler.applyFromSettings(context) + }, + ) + } + if (autoUpdateCheck) { + val interval by store.autoUpdateInterval.collectAsState() + Text( + "Interval", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AutoUpdateInterval.entries.forEach { opt -> + FilterChip( + selected = interval == opt, + onClick = { + store.setAutoUpdateInterval(opt) + UpdateScheduler.applyFromSettings(context) + }, + label = { Text(opt.label) }, + ) + } + } + } + val lastCheckMs by store.lastUpdateCheckMs.collectAsState() + val latestVc by store.latestKnownVc.collectAsState() + val latestVname by store.latestKnownVname.collectAsState() + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + val lastText = if (lastCheckMs <= 0L) { + "Never checked." + } else { + "Last checked: ${formatRelativeSince(lastCheckMs)}." + } + Text( + lastText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (latestVc > 0L && latestVc > BuildConfig.VERSION_CODE) { + val label = latestVname.ifBlank { "vc=$latestVc" } + Text( + "Update available: $label.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } + TextButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { runUpdateCheck(context) } + } + }, + ) { Text("Check now") } + } + Spacer(modifier = Modifier.height(32.dp)) Text( "Local cache", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt new file mode 100644 index 000000000..4f0eacc6c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Poll Sulkta's F-Droid repo for a newer Straw build. Returns the + * highest versionCode + the APK download URL so the worker can post + * a notification. + * + * F-Droid's index-v2.json is the canonical machine-readable shape; we + * parse just the subset we care about (versions.* → manifest.versionCode + * + file.name). `ignoreUnknownKeys` keeps us forward-compat with new + * fields fdroidserver may add later. + */ + +package com.sulkta.straw.feature.update + +import com.sulkta.straw.BuildConfig +import com.sulkta.straw.util.runCatchingCancellable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json" +private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo" + +data class UpdateInfo( + val versionCode: Long, + val versionName: String, + val apkUrl: String, +) + +object AppUpdateClient { + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + private val json = Json { ignoreUnknownKeys = true } + + /** + * Fetch + parse the repo index, return the highest-versionCode entry + * for THIS app's package. Returns null on network/parse failure (the + * caller treats null as "no update available, try again later"). + */ + suspend fun fetchLatest(): UpdateInfo? = withContext(Dispatchers.IO) { + runCatchingCancellable { + val req = Request.Builder().url(INDEX_URL).build() + val raw = http.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@runCatchingCancellable null + resp.body.string() + } + val index = json.decodeFromString(raw) + val pkg = index.packages[BuildConfig.APPLICATION_ID] + ?: return@runCatchingCancellable null + val best = pkg.versions.values + .maxByOrNull { it.manifest.versionCode } + ?: return@runCatchingCancellable null + UpdateInfo( + versionCode = best.manifest.versionCode, + versionName = best.manifest.versionName.orEmpty(), + apkUrl = "$REPO_BASE${best.file.name}", + ) + }.getOrNull() + } +} + +@Serializable +private data class FdroidIndex(val packages: Map = emptyMap()) + +@Serializable +private data class FdroidPackage(val versions: Map = emptyMap()) + +@Serializable +private data class FdroidVersion(val file: FdroidFile, val manifest: FdroidManifest) + +@Serializable +private data class FdroidFile(val name: String) + +@Serializable +private data class FdroidManifest( + val versionCode: Long, + val versionName: String? = null, +) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt new file mode 100644 index 000000000..01af9933a --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Periodic + on-demand self-update check. WorkManager fires this on the + * cadence the user picked in Settings; on cold start StrawApp also + * kicks one off so the user sees pending updates without waiting a full + * interval. The runner is small + bounded — fetch index, compare, post + * notification, done. + * + * NewPipe's biggest UX gap is silent staleness: users sit on + * months-old builds because nothing tells them to update. This worker + * + the SettingsScreen toggle close that gap without trying to be a + * full updater (Android won't let a non-system app silent-install + * APKs anyway). Tap the notification → ACTION_VIEW the APK URL → the + * system handles download + install confirm. + */ + +package com.sulkta.straw.feature.update + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.sulkta.straw.BuildConfig +import com.sulkta.straw.data.Settings +import com.sulkta.straw.util.strawLogI + +/** + * Single source of truth for "did we find a newer version?" logic. + * Touched by both the scheduled worker AND the "Check now" Settings + * button so behavior stays identical regardless of trigger. + */ +suspend fun runUpdateCheck(context: Context): UpdateInfo? { + val info = AppUpdateClient.fetchLatest() + Settings.get().setLastUpdateCheck(System.currentTimeMillis()) + if (info == null) { + strawLogI("update", "check: network/parse failure, will retry") + return null + } + if (info.versionCode <= BuildConfig.VERSION_CODE) { + strawLogI("update", "check: up to date (latest=${info.versionCode})") + Settings.get().setLatestKnownVersion(0L, "") + return null + } + strawLogI("update", "check: ${BuildConfig.VERSION_CODE} → ${info.versionCode} available") + Settings.get().setLatestKnownVersion(info.versionCode, info.versionName) + postUpdateNotification(context, info) + return info +} + +class UpdateCheckWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + if (!Settings.get().autoUpdateCheck.value) return Result.success() + runUpdateCheck(applicationContext) + // Always succeed — a failed check just retries on the next + // scheduled tick. Retry-with-backoff would burn battery for no + // gain (the index is sticky and fdroid.sulkta.com is on Cobb's + // own infra). + return Result.success() + } +} + +private const val NOTIF_CHANNEL_ID = "straw-update" +private const val NOTIF_ID = 23 + +private fun postUpdateNotification(context: Context, info: UpdateInfo) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + NOTIF_CHANNEL_ID, + "Straw updates", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Notifies when a newer Straw build is on fdroid.sulkta.com." + } + nm.createNotificationChannel(channel) + + // ACTION_VIEW on the APK URL — Chrome / system browser fetches it + // via DownloadManager and the user taps it to install. No + // INSTALL_PACKAGES permission needed; the system installer handles + // the confirm dialog. + val viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pending = PendingIntent.getActivity( + context, + 0, + viewIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val name = info.versionName.ifBlank { "vc=${info.versionCode}" } + val notif = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Straw $name available") + .setContentText("Tap to download from fdroid.sulkta.com.") + .setAutoCancel(true) + .setContentIntent(pending) + .build() + nm.notify(NOTIF_ID, notif) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt new file mode 100644 index 000000000..b24ffe6e5 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Wires the user's auto-update preferences into WorkManager. Called + * from StrawApp.onCreate (initial enqueue) and from SettingsScreen + * (re-apply when the toggle / interval flips). + * + * Uses unique-name + REPLACE so flipping the interval mid-flight just + * swaps the schedule instead of stacking workers. + */ + +package com.sulkta.straw.feature.update + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.sulkta.straw.data.AutoUpdateInterval +import com.sulkta.straw.data.Settings +import java.util.concurrent.TimeUnit + +private const val WORK_NAME = "straw-update-check" + +object UpdateScheduler { + fun applyFromSettings(context: Context) { + val s = Settings.get() + val enabled = s.autoUpdateCheck.value + val interval = s.autoUpdateInterval.value + val wm = WorkManager.getInstance(context.applicationContext) + if (!enabled) { + wm.cancelUniqueWork(WORK_NAME) + return + } + val request = PeriodicWorkRequestBuilder( + interval.minutes, + TimeUnit.MINUTES, + ).setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ).build() + wm.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } +} + +/** + * Map the user-facing AutoUpdateInterval enum to minutes for + * WorkManager. WM enforces a 15-minute floor on periodic work; any + * value below that would silently be clamped. + */ +private val AutoUpdateInterval.minutes: Long + get() = when (this) { + AutoUpdateInterval.H1 -> 60 + AutoUpdateInterval.H6 -> 6 * 60 + AutoUpdateInterval.H24 -> 24 * 60 + } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt index e021f7aeb..d1e9e1c21 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt @@ -25,3 +25,19 @@ fun formatDuration(sec: Long): String { val s = sec % 60 return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) } + +/** + * Quick "12s ago" / "3m ago" / "5h ago" / "2d ago" for the auto-update + * "Last checked" timestamp. Future timestamps (clock skew) return the + * just-now bucket. + */ +fun formatRelativeSince(ms: Long, nowMs: Long = System.currentTimeMillis()): String { + val delta = (nowMs - ms).coerceAtLeast(0L) + val sec = delta / 1000 + return when { + sec < 60 -> "${sec}s ago" + sec < 3600 -> "${sec / 60}m ago" + sec < 86_400 -> "${sec / 3600}h ago" + else -> "${sec / 86_400}d ago" + } +} From 341261584a7795c7ee35535a40ad29cf0d5b131a Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 10:44:06 -0700 Subject: [PATCH 49/87] vc=56: subs feed via RSS (5-10x faster) + hide-shorts filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strawcore — new channel_feed_rss(channel_url) and subscription_feed (bulk fan-out 50x via tokio buffer_unordered). Fetches the YouTube Atom RSS at /feeds/videos.xml?channel_id=UCxxx. Each call is ~50-150ms vs ~500ms for the InnerTube channel_info page-scrape. Deps added to strawcore wrapper Cargo.toml: reqwest (rustls-tls), quick-xml, futures. reqwest dedupes against strawcore-core's existing reqwest dep. App — SubscriptionFeedViewModel.fetchChannelInto swapped to channel_feed_rss. Parallelism cranked 12 -> 50 since each fetch is lightweight now. perChannelMax dropped 30 -> 15 (the RSS upstream cap is 15). RSS doesn't carry duration / viewCount / avatar — those backfill on tap-through via the existing streamInfo path. Avatar opportunistic-refresh dropped from this path (lazy-load on ChannelScreen open is enough). Hide-shorts content filter — new util/ContentFilter.kt with looksLikeShort() (URL /shorts/ match OR title contains '#shorts'/'#short'). Settings toggle defaults off. Filter applies at row-emit in SubsPane, SearchScreen, ChannelScreen. Paid + age-restricted stubs in place for vc=57 when strawcore-core gets the flags. Expected refresh time on 50 subs: ~30s sequential -> ~1s parallel-50 RSS. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/feed.rs | 265 ++++++++++++++++++ rust/strawcore/src/lib.rs | 1 + .../main/kotlin/com/sulkta/straw/StrawHome.kt | 6 +- .../com/sulkta/straw/data/SettingsStore.kt | 26 ++ .../straw/feature/channel/ChannelScreen.kt | 6 +- .../feature/feed/SubscriptionFeedViewModel.kt | 47 ++-- .../straw/feature/search/SearchScreen.kt | 6 +- .../straw/feature/settings/SettingsScreen.kt | 27 ++ .../com/sulkta/straw/util/ContentFilter.kt | 60 ++++ 10 files changed, 421 insertions(+), 27 deletions(-) create mode 100644 rust/strawcore/src/feed.rs create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index b6068b94b..225a8c548 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 55 -const val STRAW_VERSION_NAME = "0.1.0-BO" +const val STRAW_VERSION_CODE = 56 +const val STRAW_VERSION_NAME = "0.1.0-BP" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs new file mode 100644 index 000000000..f34a88bfd --- /dev/null +++ b/rust/strawcore/src/feed.rs @@ -0,0 +1,265 @@ +// vc=56 — fast subscription feed via YouTube's per-channel RSS endpoint. +// +// YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx` +// — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the +// per-channel `channel_info()` page-scrape that was costing ~500ms each +// (the bottleneck behind NewPipe's "pull to refresh takes 30 seconds for +// 50 subs" UX). Fan-out 50× concurrent via `futures::stream::buffer_unordered` +// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50. +// +// RSS is intentionally lossy — it returns title/url/published/thumbnail +// only. No duration, no view count, no shorts/age/paid flags. That's the +// right trade for a feed-refresh use case: tap-through still goes through +// the full stream_info path to fetch the rich metadata when actually +// needed. + +use std::time::Duration; + +use futures::stream::{self, StreamExt}; +use reqwest::Client; + +use crate::error::StrawcoreError; +use crate::search::SearchItem; + +const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id="; +const MAX_CONCURRENT: usize = 50; +const PER_CHANNEL_TIMEOUT_S: u64 = 8; + +/// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out +/// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms +/// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh +/// drops from ~5-8s to ~1s. +#[uniffi::export(async_runtime = "tokio")] +pub async fn channel_feed_rss( + channel_url: String, +) -> Result, StrawcoreError> { + crate::runtime::ensure_initialized(); + log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len()); + let client = Client::builder() + .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) + .user_agent("Mozilla/5.0 (Android; Mobile; Straw/0.1)") + .build() + .map_err(|e| StrawcoreError::Extractor { + msg: format!("http client build: {e}"), + })?; + Ok(fetch_channel_rss(&client, &channel_url).await.unwrap_or_default()) +} + +/// Bulk subscription feed fan-out — for callers that want one round-trip +/// to Rust. Currently unused by the Android app (it sticks with the +/// per-channel cache), but exposed for future desktop / web variants +/// or for a "warm everything" background prefetch. +#[uniffi::export(async_runtime = "tokio")] +pub async fn subscription_feed( + channel_urls: Vec, +) -> Result, StrawcoreError> { + crate::runtime::ensure_initialized(); + log::info!("strawcore::subscription_feed channels={}", channel_urls.len()); + if channel_urls.is_empty() { + return Ok(Vec::new()); + } + let client = Client::builder() + .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) + .user_agent("Mozilla/5.0 (Android; Mobile; Straw/0.1)") + .build() + .map_err(|e| StrawcoreError::Extractor { + msg: format!("http client build: {e}"), + })?; + + let results: Vec> = stream::iter(channel_urls.into_iter()) + .map(|url| { + let client = client.clone(); + async move { fetch_channel_rss(&client, &url).await.unwrap_or_default() } + }) + .buffer_unordered(MAX_CONCURRENT) + .collect() + .await; + + let mut flat: Vec = results.into_iter().flatten().collect(); + // Newest first by published timestamp baked into the upload_date_relative + // field at parse time — RSS already returns entries newest-first per + // channel so we mostly just need cross-channel interleave. + flat.sort_by(|a, b| b.upload_date_relative.cmp(&a.upload_date_relative)); + Ok(flat) +} + +async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option> { + let channel_id = extract_channel_id(channel_url)?; + let url = format!("{RSS_BASE}{channel_id}"); + let body = client + .get(&url) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + parse_rss(&body, channel_id) +} + +/// Extract the `UCxxx` channel ID from a channel URL. Handles the +/// common shapes: +/// * `https://www.youtube.com/channel/UCxxx...` +/// * `https://www.youtube.com/UCxxx...` (canonical clone) +/// * raw `UCxxx...` (already an ID) +/// +/// `@handle` URLs are NOT supported here — RSS requires the channel ID. +/// Callers that only have an @handle should resolve via channel_info() +/// once, cache the ID into Subscriptions, and pass the ID forever after. +fn extract_channel_id(input: &str) -> Option { + let trimmed = input.trim(); + if let Some(stripped) = trimmed.strip_prefix("https://www.youtube.com/channel/") { + return Some(stripped.split('/').next()?.to_string()); + } + if let Some(stripped) = trimmed.strip_prefix("https://youtube.com/channel/") { + return Some(stripped.split('/').next()?.to_string()); + } + if trimmed.starts_with("UC") && trimmed.len() >= 22 && trimmed.len() <= 26 { + return Some(trimmed.to_string()); + } + None +} + +fn parse_rss(body: &str, channel_id: String) -> Option> { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + + let mut buf = Vec::new(); + let mut items: Vec = Vec::new(); + + // Per-entry scratch. + let mut in_entry = false; + let mut depth = 0u8; + let mut video_id = String::new(); + let mut title = String::new(); + let mut uploader = String::new(); + let mut uploader_url = String::new(); + let mut thumbnail: Option = None; + let mut published = String::new(); + + // What text-collecting state we're in. Replaced per element open. + let mut text_target: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let local = local_name(e.name().as_ref()); + if local == "entry" { + in_entry = true; + depth = 0; + video_id.clear(); + title.clear(); + uploader.clear(); + uploader_url.clear(); + thumbnail = None; + published.clear(); + } + if !in_entry { + continue; + } + depth = depth.saturating_add(1); + text_target = match local { + "videoId" => Some(TextTarget::VideoId), + "title" if depth <= 2 => Some(TextTarget::Title), + "name" => Some(TextTarget::UploaderName), + "uri" => Some(TextTarget::UploaderUrl), + "published" => Some(TextTarget::Published), + _ => None, + }; + } + Ok(Event::Empty(e)) => { + if !in_entry { + continue; + } + let local = local_name(e.name().as_ref()); + // is self-closing. + if local == "thumbnail" { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"url" { + if let Ok(v) = attr.unescape_value() { + thumbnail = Some(v.into_owned()); + } + } + } + } + } + Ok(Event::Text(t)) => { + if !in_entry { + continue; + } + let Ok(s) = t.unescape() else { continue }; + let s = s.as_ref(); + match text_target { + Some(TextTarget::VideoId) => video_id.push_str(s), + Some(TextTarget::Title) => title.push_str(s), + Some(TextTarget::UploaderName) => uploader.push_str(s), + Some(TextTarget::UploaderUrl) => uploader_url.push_str(s), + Some(TextTarget::Published) => published.push_str(s), + None => {} + } + } + Ok(Event::End(e)) => { + if !in_entry { + continue; + } + let local = local_name(e.name().as_ref()); + if local == "entry" { + if !video_id.is_empty() { + items.push(SearchItem { + url: format!("https://www.youtube.com/watch?v={video_id}"), + title: title.clone(), + uploader: uploader.clone(), + uploader_url: if uploader_url.is_empty() { + Some(format!("https://www.youtube.com/channel/{channel_id}")) + } else { + Some(uploader_url.clone()) + }, + thumbnail: thumbnail.clone(), + duration_seconds: 0, + view_count: 0, + // RSS gives ISO-8601 timestamps. We pass them + // through unchanged — newer-first sorting on + // raw ISO strings is correct. + upload_date_relative: published.clone(), + }); + } + in_entry = false; + depth = 0; + } else { + depth = depth.saturating_sub(1); + } + text_target = None; + } + Ok(Event::Eof) => break, + Err(_) => return None, + _ => {} + } + buf.clear(); + } + Some(items) +} + +enum TextTarget { + VideoId, + Title, + UploaderName, + UploaderUrl, + Published, +} + +/// Strip the namespace prefix off an XML element name. YouTube's feed +/// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only +/// care about the local part — namespace-vs-local distinguishing +/// would just bloat the matcher. +fn local_name(qualified: &[u8]) -> &str { + let s = std::str::from_utf8(qualified).unwrap_or(""); + match s.rfind(':') { + Some(idx) => &s[idx + 1..], + None => s, + } +} diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index 2329d55ce..c8353502a 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -12,6 +12,7 @@ use std::sync::Once; mod channel; mod error; +mod feed; mod runtime; mod search; mod stream; diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 7510d452b..322b7bd4f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -310,9 +310,11 @@ private fun SubsPane( watches.map { it.videoId }.filter { it.isNotBlank() }.toSet() } - val filteredItems = remember(feed.items, hideWatched, watchedIds) { - if (!hideWatched) feed.items + val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) { + val watchFiltered = if (!hideWatched) feed.items else feed.items.filterNot { extractVideoId(it.url) in watchedIds } + com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts) } // Reset pagination when the underlying list changes so the user // doesn't end up looking at "no more items" after a refresh. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 208de8be5..60fc8f147 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -84,6 +84,7 @@ private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1" private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1" private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1" private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1" +private const val KEY_HIDE_SHORTS = "hide_shorts_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -179,6 +180,24 @@ class SettingsStore(context: Context) { ) val latestKnownVname: StateFlow = _latestKnownVname.asStateFlow() + /** + * Hide YouTube Shorts everywhere. Detection is multi-signal because + * each surface gives different hints: + * - Search + ChannelScreen results: URL pattern `/shorts/` is + * reliable (strawcore preserves it). + * - Subscription RSS feed: URLs come back as canonical `watch?v=` + * so URL alone won't trip; fall back to title containing + * "#shorts" / "#Shorts" / "(shorts)" which most short uploaders + * include. + * Filter is best-effort — a hand-tagged short with a clean title + * in the subs feed will slip through until vc=57 plumbs an + * isShort flag through strawcore-core. + */ + private val _hideShorts = MutableStateFlow( + sp.getBoolean(KEY_HIDE_SHORTS, false), + ) + val hideShorts: StateFlow = _hideShorts.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -276,6 +295,13 @@ class SettingsStore(context: Context) { .apply() } + fun setHideShorts(hide: Boolean) { + val before = _hideShorts.value + if (before == hide) return + _hideShorts.value = hide + sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 6ecd48a08..3cb44dd45 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -145,7 +145,11 @@ fun ChannelScreen( } HorizontalDivider() } - items(state.videos) { item -> + val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + val filteredVideos = remember(state.videos, hideShorts) { + com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts) + } + items(filteredVideos) { item -> ChannelVideoRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 828a6b36b..302d898b4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -111,19 +111,23 @@ class SubscriptionFeedViewModel : ViewModel() { private val perChannelTimeoutMs = 10_000L /** - * Parallel network fetches. 12 instead of 8 — with the disk cache - * now buffering UI from network latency, the dominant cost is - * end-to-end batch completion, which is bottle-necked by the - * slowest network round-trip in each parallel group. + * Parallel network fetches. Cranked from 12 → 50 in vc=56 alongside + * the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload + * instead of a ~150KB InnerTube channel-page scrape — Tokio's + * `buffer_unordered` inside `subscription_feed()` handles >50 + * concurrent without breaking a sweat, and the Kotlin gate just + * keeps the launch fan-out bounded so we don't blow the file- + * descriptor budget on a 200-sub user. */ - private val parallelism = 12 + private val parallelism = 50 /** - * Videos pulled per channel. Bumped from 5 → 30 so "show me - * everything new from my subs" actually has body to it; cheap to - * keep in memory at this size (30 subs * 30 videos = 900 max). + * Videos pulled per channel. RSS returns up to 15 most-recent + * videos per channel — that's the upstream cap, so 15 is our + * effective ceiling here. We sort + interleave across all subs + * client-side after the fan-out completes. */ - private val perChannelMax = 30 + private val perChannelMax = 15 /** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */ private var inFlight: Job? = null @@ -223,26 +227,27 @@ class SubscriptionFeedViewModel : ViewModel() { } private suspend fun fetchChannelInto(ch: ChannelRef) { + // vc=56: swapped uniffi.strawcore.channelInfo() (~500ms each, + // full InnerTube page scrape with JS eval) for the RSS feed + // (~50-150ms each, tiny Atom XML). Same fan-out architecture, + // ~5-10× faster. Avatar backfill is skipped on this path — + // RSS doesn't carry avatars; the existing avatar lazy-loads + // when the user taps into the channel screen. val outcome = withTimeoutOrNull(perChannelTimeoutMs) { runCatchingCancellable { - val info = uniffi.strawcore.channelInfo(ch.url) - // Opportunistic avatar refresh: if our stored ChannelRef - // didn't capture an avatar at subscribe-time (channel - // header parser missed it, or user subscribed before the - // page loaded), backfill from the channel info now. - val freshAvatar = info.avatar - if (!freshAvatar.isNullOrBlank() && freshAvatar != ch.avatar) { - runCatchingCancellable { - Subscriptions.get().updateAvatar(ch.url, freshAvatar) - } - } - info.videos.take(perChannelMax).map { v -> + val videos = uniffi.strawcore.channelFeedRss(ch.url) + videos.take(perChannelMax).map { v -> StreamItem( url = v.url, title = v.title.ifBlank { "(no title)" }, uploader = v.uploader.ifBlank { ch.name }, uploaderUrl = v.uploaderUrl ?: ch.url, thumbnail = v.thumbnail, + // RSS doesn't carry duration or view count. + // These backfill on tap-through when the user + // opens the detail screen and we resolve full + // streamInfo. 0 means "unknown" — the row + // renderer hides the badges when 0. durationSeconds = v.durationSeconds, viewCount = v.viewCount, uploadDateRelative = v.uploadDateRelative, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 06075d32d..4f4adebfa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -162,11 +162,15 @@ fun SearchScreen( modifier = Modifier.padding(bottom = 4.dp), ) } + val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + val filteredResults = remember(state.results, hideShorts) { + com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts) + } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = rememberBottomContentPadding(), ) { - items(state.results) { item -> + items(filteredResults) { item -> ResultRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 4fa6e7108..ca1b7b6c2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -336,6 +336,33 @@ fun SettingsScreen() { onCheckedChange = { store.setAutoResume(it) }, ) } + val hideShorts by store.hideShorts.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Hide Shorts", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Drop /shorts/ URLs from search + channel pages " + + "and best-effort filter (\"#shorts\" tag) on the " + + "subs feed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = hideShorts, + onCheckedChange = { store.setHideShorts(it) }, + ) + } Spacer(modifier = Modifier.height(32.dp)) Text( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt new file mode 100644 index 000000000..f7f244c62 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Heuristics for the hide-shorts / hide-paid / hide-age content filters. + * Pure functions on StreamItem so any list-rendering site can call them + * with one line at row-emit time. + * + * vc=56 ships only the shorts heuristic — paid/age require strawcore + * flag plumbing landing in vc=57. The empty-stub fns are here so the + * call sites we add now don't need to change when the flags arrive. + */ + +package com.sulkta.straw.util + +import com.sulkta.straw.feature.search.StreamItem + +/** + * Best-effort short-video detector: + * - URL pattern `/shorts/` — reliable signal from search + + * channel pages (strawcore preserves the original URL shape). + * - Title contains `#shorts` / `#short` / "(shorts)" — fallback for + * items where the URL is the canonical `watch?v=` form (RSS feed + * items always come through this way). + */ +fun looksLikeShort(item: StreamItem): Boolean { + if ("/shorts/" in item.url) return true + val t = item.title.lowercase() + return "#shorts" in t || "#short" in t || "(shorts)" in t +} + +/** + * Placeholder until vc=57 adds an isPaid flag via strawcore-core. + * Currently always false — the hide-paid toggle still shows up in + * Settings so the user can pre-opt-in for when it lights up. + */ +fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false + +/** + * Placeholder until vc=57 adds an isAgeRestricted flag. Same shape + * as looksLikePaid. + */ +fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false + +/** + * Combined filter applied at row-emit. Returns the items to keep based + * on the current Settings flags. Centralized here so the policy is + * defined in one place; each calling LazyColumn just maps its source + * list through this. + */ +fun applyContentFilters( + items: List, + hideShorts: Boolean, + hidePaid: Boolean = false, + hideAgeRestricted: Boolean = false, +): List = items.filterNot { item -> + (hideShorts && looksLikeShort(item)) || + (hidePaid && looksLikePaid(item)) || + (hideAgeRestricted && looksLikeAgeRestricted(item)) +} From 3a57696b4637db858915c93bbc918f3af640eddc Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 10:46:26 -0700 Subject: [PATCH 50/87] vc=56 fixup: actually add reqwest+quick-xml+futures to Cargo.toml Earlier Edit's old_string didn't match the file shape so the dep additions never landed. Re-adding properly after android_logger. --- rust/strawcore/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 1cd5ce697..630ef94ef 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -37,6 +37,13 @@ thiserror = "1" # Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`. log = "0.4" android_logger = { version = "0.14", default-features = false } +# vc=56 — subscription RSS feed fan-out. reqwest dedupes against +# strawcore-core's already-pulled reqwest; quick-xml is small (~200KB); +# futures for buffer_unordered. rustls-tls avoids the NDK openssl headers +# headache. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } +quick-xml = "0.36" +futures = "0.3" [build-dependencies] uniffi = { version = "0.28", features = ["build"] } From 12acf41c08fbf65c9b051155c56bef9b8e8e1fdc Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 10:47:45 -0700 Subject: [PATCH 51/87] vc=56 fixup: bind QName temporary before passing to local_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quick-xml's BytesStart::name() returns a borrowed QName; calling .as_ref() on it produced a &[u8] that outlived the QName by one expression — borrowck E0716. Hoist the QName to a local so it lives the full match arm. --- rust/strawcore/src/feed.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index f34a88bfd..ceaf572b4 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -148,7 +148,8 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { - let local = local_name(e.name().as_ref()); + let name = e.name(); + let local = local_name(name.as_ref()); if local == "entry" { in_entry = true; depth = 0; @@ -176,7 +177,8 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { if !in_entry { continue; } - let local = local_name(e.name().as_ref()); + let name = e.name(); + let local = local_name(name.as_ref()); // is self-closing. if local == "thumbnail" { for attr in e.attributes().flatten() { @@ -207,7 +209,8 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { if !in_entry { continue; } - let local = local_name(e.name().as_ref()); + let name = e.name(); + let local = local_name(name.as_ref()); if local == "entry" { if !video_id.is_empty() { items.push(SearchItem { From 50f4ce0a6c85dfaeb353204c0bd40c9341cebb6d Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 10:50:37 -0700 Subject: [PATCH 52/87] vc=56 fixup: hoist hideShorts collectAsState out of LazyListScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LazyColumn's content lambda is LazyListScope, NOT @Composable, so collectAsState() + remember() can't live inside the block body. Lift both above the LazyColumn call (still inside the when{}'s else branch). ChannelScreen — SubsPane and SearchScreen already had it in the right scope. --- .../sulkta/straw/feature/channel/ChannelScreen.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 3cb44dd45..bca2e6641 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -88,7 +88,15 @@ fun ChannelScreen( Text("error: ${state.error}", color = MaterialTheme.colorScheme.error) } - else -> LazyColumn( + else -> { + // Hoisted to outer Composable scope — LazyListScope is NOT + // @Composable so collectAsState / remember can't live inside + // the LazyColumn block. + val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + val filteredVideos = remember(state.videos, hideShorts) { + com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts) + } + LazyColumn( modifier = Modifier.fillMaxSize().statusBarsPadding(), contentPadding = rememberBottomContentPadding(), ) { @@ -145,10 +153,6 @@ fun ChannelScreen( } HorizontalDivider() } - val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() - val filteredVideos = remember(state.videos, hideShorts) { - com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts) - } items(filteredVideos) { item -> ChannelVideoRow( item = item, @@ -165,6 +169,7 @@ fun ChannelScreen( HorizontalDivider() } } + } } } From 8dec2f262172fbe51b34c311e1f47703f1d08095 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:16:00 -0700 Subject: [PATCH 53/87] vc=57: hide stale inline player frame during video switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb-reported 2026-05-26: tapping a related/search/subs video while another is playing rendered the NEW detail page (title, description) with the OLD video's last frame visible in the inline player slot. Root cause — there's a window between streamInfo resolving for the new URL and setPlayingFrom landing on the controller. PlayerView bound to the controller renders the previous video's surface during that window because the controller's MediaItem hasn't swapped yet. Fix — observe NowPlaying.current and add a branch in InlinePlayer's state-when that renders thumbnail + spinner when the controller is still on a different streamUrl. Branch sits above the PlayerView else-arm so the stale surface never gets attached. Flips to PlayerView the moment NowPlaying.claim() lands the new URL. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../straw/feature/detail/VideoDetailScreen.kt | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 225a8c548..e4f73c6c8 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 56 -const val STRAW_VERSION_NAME = "0.1.0-BP" +const val STRAW_VERSION_CODE = 57 +const val STRAW_VERSION_NAME = "0.1.0-BQ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index e10c841f9..447c7f79e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -776,6 +776,14 @@ private fun InlinePlayer( onDispose { c?.removeListener(listener) } } + // Track whether the shared controller has actually swapped over to + // THIS video's stream. Until it does (the brief window between + // streamInfo resolving and setPlayingFrom + setMediaItem landing), + // binding PlayerView to the controller would render the PREVIOUS + // video's frame under the new detail page — exactly the "new page, + // old video" bug. + val nowPlaying by NowPlaying.current.collectAsStateWithLifecycle() + val controllerOnThisVideo = nowPlaying?.streamUrl == streamUrl Box(modifier = modifier, contentAlignment = Alignment.Center) { when { controller == null || state.loading -> CircularProgressIndicator(color = Color.White) @@ -794,6 +802,22 @@ private fun InlinePlayer( color = Color.White, modifier = Modifier.padding(16.dp), ) + // Stream resolved for THIS URL but the controller hasn't + // actually swapped media items yet — show the thumbnail + // with a spinner. Without this, the PlayerView below would + // bind to the controller and render the OUTGOING video's + // last frame while the new detail page chrome shows the + // new title/description. Bug reported 2026-05-26. + !controllerOnThisVideo -> { + if (!thumbnail.isNullOrBlank()) { + AsyncImage( + model = thumbnail, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + CircularProgressIndicator(color = Color.White) + } else -> { AndroidView( factory = { ctx -> From 7fff36c5e35125004ab7c56f25961d8a0c8982b0 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:27:34 -0700 Subject: [PATCH 54/87] vc=58: parallel SponsorBlock + RYD fetch on video open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequential withContext blocks left the slower of the two requests fully serialized behind the faster one — 200-500ms wasted per video open. async{}+await in a coroutineScope runs both on Dispatchers.IO concurrently. Saves the slower-task latency on every detail-screen load. Rust port was overscoped — the dominant cost is network latency, not parse, so a UniFFI hop wouldn't help and the parallelization fix is a 5-line Kotlin change. Updated the Rust port plan memo accordingly. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +-- .../feature/detail/VideoDetailViewModel.kt | 25 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e4f73c6c8..b65da1030 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 57 -const val STRAW_VERSION_NAME = "0.1.0-BQ" +const val STRAW_VERSION_CODE = 58 +const val STRAW_VERSION_NAME = "0.1.0-BR" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 45336a6bc..f20864efa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -29,6 +29,8 @@ import com.sulkta.straw.util.runCatchingCancellable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -160,13 +162,24 @@ class VideoDetailViewModel : ViewModel() { } } - val ryd = withContext(Dispatchers.IO) { - runCatchingCancellable { RydClient.fetch(videoId) }.getOrNull() - } + // RYD + SponsorBlock in parallel — both are independent + // network round-trips that block the detail UI. Running + // them sequentially via two withContext blocks left the + // slower one fully serialized behind the faster one + // (~200-500ms wasted per video open). async{}.await() + // on Dispatchers.IO closes that gap. val sbCats = Settings.get().sbCategories.value.map { it.key } - val segments = if (sbCats.isEmpty()) emptyList() else withContext(Dispatchers.IO) { - runCatchingCancellable { SponsorBlockClient.fetch(videoId, sbCats) } - .getOrDefault(emptyList()) + val (ryd, segments) = coroutineScope { + val rydDeferred = async(Dispatchers.IO) { + runCatchingCancellable { RydClient.fetch(videoId) }.getOrNull() + } + val sbDeferred = async(Dispatchers.IO) { + if (sbCats.isEmpty()) emptyList() + else runCatchingCancellable { + SponsorBlockClient.fetch(videoId, sbCats) + }.getOrDefault(emptyList()) + } + rydDeferred.await() to sbDeferred.await() } val related = info.related.map { r -> From 2e75938f4e3f2e507f568f274ff620957785792e Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:33:53 -0700 Subject: [PATCH 55/87] vc=59: per-store cache caps + TTL + Clear all caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing cache controls Cobb specifically asked for. Each SharedPreferences-backed store now reads its cap from Settings instead of a hardcoded constant: - History watches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 50) - History searches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 20) - Resume positions: same options (was fixed 500) - Search results cache: same options (was fixed 30 queries) Each store also enforces a hard ceiling (100k for History + Resume, 5k for SearchCache) so Unlimited doesn't OOM SP on a hostile import. New global Cache TTL: 1 day / 7 days / 30 days / 1 year / Forever. Drops subs feed + search cache entries older than the cutoff on every read. Defaults to 30 days. Settings UI — new 'Cache & history limits' section inside the existing Local cache block with one chip-row per cap + the TTL chip-row + a 'Clear all caches' button that nukes FeedCache, SearchCache, ResumePositions, History.watches, History.searches on one tap. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../com/sulkta/straw/data/FeedCacheStore.kt | 13 ++- .../com/sulkta/straw/data/HistoryStore.kt | 47 +++++--- .../sulkta/straw/data/ResumePositionsStore.kt | 23 +++- .../com/sulkta/straw/data/SearchCacheStore.kt | 24 +++- .../com/sulkta/straw/data/SettingsStore.kt | 110 ++++++++++++++++++ .../straw/feature/settings/SettingsScreen.kt | 104 +++++++++++++++++ 7 files changed, 296 insertions(+), 29 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index b65da1030..72b5ffa36 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 58 -const val STRAW_VERSION_NAME = "0.1.0-BR" +const val STRAW_VERSION_CODE = 59 +const val STRAW_VERSION_NAME = "0.1.0-BS" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index deb46f2f6..af886ccf5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -38,10 +38,19 @@ class FeedCacheStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } - /** Snapshot of the disk cache. Returns empty map if nothing saved. */ + /** + * Snapshot of the disk cache, filtered by the user-configured TTL. + * Returns empty map if nothing saved or everything expired. vc=59 — + * Settings.cacheTtl.isForever short-circuits the filter; finite TTLs + * drop entries whose fetchedAt is older than (now - ttl). + */ fun load(): Map = runCatching { val s = sp.getString(KEY, null) ?: return emptyMap() - json.decodeFromString>(s) + val raw = json.decodeFromString>(s) + val ttl = Settings.get().cacheTtl.value + if (ttl.isForever) return raw + val cutoff = System.currentTimeMillis() - ttl.ms + raw.filterValues { it.fetchedAt >= cutoff } }.getOrDefault(emptyMap()) /** Atomic write. Caller is responsible for diffing if needed. */ diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index a785c58c2..351e9b7b7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Recent watches + recent searches backed by SharedPreferences JSON - * blobs. Capped to MAX_WATCHES / MAX_SEARCHES. Graduates to Room when + * blobs. Capped to maxWatches() / maxSearches(). Graduates to Room when * a real query pattern (date ranges, full-text search) shows up. */ @@ -31,13 +31,30 @@ data class WatchHistoryItem( private const val PREFS = "straw_history" private const val KEY_WATCHES = "watches_v1" private const val KEY_SEARCHES = "searches_v1" -private const val MAX_WATCHES = 50 -private const val MAX_SEARCHES = 20 + +/** + * Pre-vc=59 hard limits. Still used as the absolute upper bound when + * Settings.historyWatchesCap is CacheCap.Unlimited — we don't want to + * allow truly-uncapped growth that could OOM SP on a hostile import. + * Any user-picked cap above this is silently floored to MAX_*_HARD. + */ +private const val maxWatches()_HARD = 100_000 +private const val maxSearches()_HARD = 100_000 class HistoryStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } + private fun maxWatches(): Int { + val cap = Settings.get().historyWatchesCap.value.value + return cap.coerceAtMost(maxWatches()_HARD) + } + + private fun maxSearches(): Int { + val cap = Settings.get().historySearchesCap.value.value + return cap.coerceAtMost(maxSearches()_HARD) + } + private val _watches = MutableStateFlow(loadWatches()) val watches: StateFlow> = _watches.asStateFlow() @@ -51,7 +68,7 @@ class HistoryStore(context: Context) { // is exactly the bug updateAndGet avoids. val next = _watches.updateAndGet { current -> val without = current.filterNot { it.videoId == item.videoId } - (listOf(now) + without).take(MAX_WATCHES) + (listOf(now) + without).take(maxWatches()) } sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() } @@ -63,7 +80,7 @@ class HistoryStore(context: Context) { * * Walks input newest-first (input is fed oldest-first), filters * blanks + already-seen videoIds, prepends to current, then takes - * MAX_WATCHES. Imports WIN over older current entries when the + * maxWatches(). Imports WIN over older current entries when the * store is at the cap — the vc=37 first cut silently discarded * the whole import in that case (round-3 audit HIGH-1). * @@ -76,7 +93,7 @@ class HistoryStore(context: Context) { * store on this call (counts new videoIds; duplicates of * already-recorded entries don't count). Round-4 audit HIGH-7 — * SettingsImport previously reported `size_after - size_before` - * which lies when the store was at MAX_WATCHES (post-state can + * which lies when the store was at maxWatches() (post-state can * be 50 = pre-state even when 20 imports landed and 20 older * locals were truncated to make room). */ @@ -92,11 +109,11 @@ class HistoryStore(context: Context) { val seen = HashSet(current.size + items.size) current.forEach { seen.add(it.videoId) } // Build the import list newest-first. Capped at - // MAX_WATCHES on its own so we don't over-allocate + // maxWatches() on its own so we don't over-allocate // even on a 50k-row hostile export. - val fresh = ArrayList(MAX_WATCHES) + val fresh = ArrayList(maxWatches()) val it = items.listIterator(items.size) - while (it.hasPrevious() && fresh.size < MAX_WATCHES) { + while (it.hasPrevious() && fresh.size < maxWatches()) { val item = it.previous() if (item.videoId.isBlank()) continue if (!seen.add(item.videoId)) continue @@ -105,8 +122,8 @@ class HistoryStore(context: Context) { } if (fresh.isEmpty()) return@updateAndGet current // Combine + cap. take() truncates older `current` entries - // when we'd exceed MAX_WATCHES, so imports always land. - (fresh + current).take(MAX_WATCHES) + // when we'd exceed maxWatches(), so imports always land. + (fresh + current).take(maxWatches()) } if (next !== before) { sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() @@ -133,9 +150,9 @@ class HistoryStore(context: Context) { counter.set(0) val seen = HashSet(current.size + queries.size) current.forEach { seen.add(it.lowercase()) } - val fresh = ArrayList(MAX_SEARCHES) + val fresh = ArrayList(maxSearches()) val it = queries.listIterator(queries.size) - while (it.hasPrevious() && fresh.size < MAX_SEARCHES) { + while (it.hasPrevious() && fresh.size < maxSearches()) { val q = it.previous().trim() if (q.isEmpty()) continue if (!seen.add(q.lowercase())) continue @@ -143,7 +160,7 @@ class HistoryStore(context: Context) { counter.incrementAndGet() } if (fresh.isEmpty()) return@updateAndGet current - (fresh + current).take(MAX_SEARCHES) + (fresh + current).take(maxSearches()) } if (next !== before) { sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() @@ -156,7 +173,7 @@ class HistoryStore(context: Context) { if (q.isEmpty()) return val next = _searches.updateAndGet { current -> val without = current.filterNot { it.equals(q, ignoreCase = true) } - (listOf(q) + without).take(MAX_SEARCHES) + (listOf(q) + without).take(maxSearches()) } sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 3b4fa5f57..db4621e98 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -8,7 +8,7 @@ * on player teardown, keyed by videoId so resume works across stream * URL rotations (googlevideo URLs rotate per session). * - * SharedPreferences-lite, single JSON blob, capped at MAX_RESUMES with + * SharedPreferences-lite, single JSON blob, capped at maxResumes() with * oldest-eviction. Same shape as HistoryStore — graduates to Room if a * real query pattern shows up. */ @@ -34,8 +34,14 @@ data class ResumePosition( private const val PREFS = "straw_resume_positions" private const val KEY_POSITIONS = "positions_v1" -/** Cap on retained per-video resume entries — prune oldest on overflow. */ -private const val MAX_RESUMES = 500 +/** + * Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the + * user-picked cap from Settings.resumePositionsCap is silently floored + * to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here + * than HistoryStore because resume entries are tiny (~50 bytes each) + * vs WatchHistoryItem's ~250 bytes. + */ +private const val maxResumes()_HARD = 100_000 /** * Skip writes for trivial positions — auto-resuming from 0:03 is more @@ -58,6 +64,11 @@ class ResumePositionsStore(context: Context) { private val _positions = MutableStateFlow(load()) val positions: StateFlow> = _positions.asStateFlow() + private fun maxResumes(): Int { + val cap = Settings.get().resumePositionsCap.value.value + return cap.coerceAtMost(maxResumes()_HARD) + } + /** * Record (or update) the scrub-point for a video. Skipped silently * when: @@ -84,13 +95,13 @@ class ResumePositionsStore(context: Context) { val before = _positions.value val next = _positions.updateAndGet { current -> val withEntry = current + (videoId to entry) - if (withEntry.size > MAX_RESUMES) { + if (withEntry.size > maxResumes()) { // Drop oldest by lastWatchedAt — newcomers always land // because the entry we just added is by definition the - // freshest. take(MAX_RESUMES) of the sorted-desc list. + // freshest. take(maxResumes()) of the sorted-desc list. withEntry.entries .sortedByDescending { it.value.lastWatchedAt } - .take(MAX_RESUMES) + .take(maxResumes()) .associate { it.key to it.value } } else { withEntry diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index ff917b7e4..5fad63def 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -41,7 +41,7 @@ data class SearchCacheEntry( private const val PREFS = "straw_search_cache" private const val KEY = "search_v1" -private const val MAX_QUERIES = 30 +private const val maxQueries()_HARD = 5000 private const val MAX_ITEMS_PER_QUERY = 20 class SearchCacheStore(context: Context) { @@ -51,13 +51,29 @@ class SearchCacheStore(context: Context) { private val _entries = MutableStateFlow(loadFromDisk()) val entries: StateFlow> = _entries.asStateFlow() + private fun maxQueries(): Int = + Settings.get().searchCacheCap.value.value.coerceAtMost(maxQueries()_HARD) + + /** + * Filter out entries older than the configured TTL. Called on every + * read path so stale data never surfaces. Forever (ttl.isForever) + * is a no-op. Returns a fresh list — caller decides whether to + * persist the trim. + */ + private fun filterByTtl(items: List): List { + val ttl = Settings.get().cacheTtl.value + if (ttl.isForever) return items + val cutoff = System.currentTimeMillis() - ttl.ms + return items.filter { it.fetchedAt >= cutoff } + } + /** Snapshot of the cache. Used by the reactive search filter. */ - fun load(): List = _entries.value + fun load(): List = filterByTtl(_entries.value) /** * Record a freshly-fetched query result. Idempotent: a re-run of * the same query overwrites the prior entry rather than duplicating. - * Oldest entries fall off when MAX_QUERIES is exceeded. + * Oldest entries fall off when maxQueries() is exceeded. * * Atomic via updateAndGet — concurrent records don't lose entries. */ @@ -68,7 +84,7 @@ class SearchCacheStore(context: Context) { val now = System.currentTimeMillis() val next = _entries.updateAndGet { current -> val without = current.filterNot { it.query.equals(q, ignoreCase = true) } - (listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES) + (listOf(SearchCacheEntry(q, now, capped)) + without).take(maxQueries()) } sp.edit().putString(KEY, json.encodeToString(next)).apply() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 60fc8f147..500c330ed 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -69,6 +69,41 @@ enum class AutoUpdateInterval(val label: String) { H24("Every 24 hours"), } +/** + * User-facing cache caps. Each store's hard limit is the cap's value; + * `Int.MAX_VALUE` means "unlimited" (the store grows without trimming). + * Defaults match the pre-vc=59 hardcoded constants so existing data + * keeps the same shape until the user picks something different. + */ +enum class CacheCap(val label: String, val value: Int) { + Tiny("50", 50), + Small("200", 200), + Medium("1000", 1000), + Large("10000", 10000), + Unlimited("Unlimited", Int.MAX_VALUE); + + companion object { + fun nearest(target: Int): CacheCap = + entries.firstOrNull { it.value == target } ?: Unlimited + } +} + +/** + * TTL knob for time-decayed caches (subs feed + search results). 0 + * means "forever" — entries never time out and only fall off via + * size cap. Shorter TTLs reclaim disk on devices with tight storage. + */ +enum class CacheTtl(val label: String, val days: Int) { + D1("1 day", 1), + D7("7 days", 7), + D30("30 days", 30), + D365("1 year", 365), + Forever("Forever", 0); + + val isForever: Boolean get() = days == 0 + val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_MAX_RES = "max_resolution_v1" @@ -85,6 +120,11 @@ private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1" private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1" private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1" private const val KEY_HIDE_SHORTS = "hide_shorts_v1" +private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1" +private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1" +private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1" +private const val KEY_CACHE_SEARCH = "cache_search_v1" +private const val KEY_CACHE_TTL = "cache_ttl_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -198,6 +238,38 @@ class SettingsStore(context: Context) { ) val hideShorts: StateFlow = _hideShorts.asStateFlow() + /** + * Per-store cache caps. Each store reads its cap from the matching + * StateFlow on every prune cycle so flipping the toggle in Settings + * takes effect immediately (next write trims to the new cap; reads + * are unbounded since they're already in memory). + * + * Defaults match the pre-vc=59 hardcoded constants so first-launch + * behavior is unchanged from prior versions. + */ + private val _historyWatchesCap = MutableStateFlow( + CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)), + ) + val historyWatchesCap: StateFlow = _historyWatchesCap.asStateFlow() + + private val _historySearchesCap = MutableStateFlow( + loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20), + ) + val historySearchesCap: StateFlow = _historySearchesCap.asStateFlow() + + private val _resumePositionsCap = MutableStateFlow( + loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500), + ) + val resumePositionsCap: StateFlow = _resumePositionsCap.asStateFlow() + + private val _searchCacheCap = MutableStateFlow( + loadCap(KEY_CACHE_SEARCH, default = 30), + ) + val searchCacheCap: StateFlow = _searchCacheCap.asStateFlow() + + private val _cacheTtl = MutableStateFlow(loadCacheTtl()) + val cacheTtl: StateFlow = _cacheTtl.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -302,6 +374,44 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply() } + fun setHistoryWatchesCap(cap: CacheCap) { + if (_historyWatchesCap.value == cap) return + _historyWatchesCap.value = cap + sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply() + } + + fun setHistorySearchesCap(cap: CacheCap) { + if (_historySearchesCap.value == cap) return + _historySearchesCap.value = cap + sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply() + } + + fun setResumePositionsCap(cap: CacheCap) { + if (_resumePositionsCap.value == cap) return + _resumePositionsCap.value = cap + sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply() + } + + fun setSearchCacheCap(cap: CacheCap) { + if (_searchCacheCap.value == cap) return + _searchCacheCap.value = cap + sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply() + } + + fun setCacheTtl(ttl: CacheTtl) { + if (_cacheTtl.value == ttl) return + _cacheTtl.value = ttl + sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply() + } + + private fun loadCap(key: String, default: Int): CacheCap = + CacheCap.nearest(sp.getInt(key, default)) + + private fun loadCacheTtl(): CacheTtl { + val name = sp.getString(KEY_CACHE_TTL, null) ?: return CacheTtl.D30 + return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30 + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index ca1b7b6c2..0eeb56246 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -47,7 +47,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.material3.FilterChip import com.sulkta.straw.BuildConfig import com.sulkta.straw.data.AutoUpdateInterval +import com.sulkta.straw.data.CacheCap +import com.sulkta.straw.data.CacheTtl import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.data.Resume import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.util.formatRelativeSince @@ -529,6 +532,65 @@ fun SettingsScreen() { ) } + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Cache & history limits", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Pick how much to keep. Unlimited = no auto-pruning. Old " + + "entries beyond a TTL are dropped on read.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + CacheCapRow( + label = "Watch history", + selected = store.historyWatchesCap.collectAsState().value, + onPick = { store.setHistoryWatchesCap(it) }, + ) + CacheCapRow( + label = "Search history", + selected = store.historySearchesCap.collectAsState().value, + onPick = { store.setHistorySearchesCap(it) }, + ) + CacheCapRow( + label = "Resume positions", + selected = store.resumePositionsCap.collectAsState().value, + onPick = { store.setResumePositionsCap(it) }, + ) + CacheCapRow( + label = "Search results cache", + selected = store.searchCacheCap.collectAsState().value, + onPick = { store.setSearchCacheCap(it) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Cache TTL", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Drop subs feed + search cache entries older than this.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val ttl by store.cacheTtl.collectAsState() + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CacheTtl.entries.forEach { opt -> + FilterChip( + selected = ttl == opt, + onClick = { store.setCacheTtl(opt) }, + label = { Text(opt.label) }, + ) + } + } + Spacer(modifier = Modifier.height(32.dp)) Text( "History", @@ -544,6 +606,20 @@ fun SettingsScreen() { Text("Clear searches") } } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + runCatching { FeedCache.get().clear() } + runCatching { SearchCache.get().clear() } + runCatching { Resume.get().clearAll() } + runCatching { History.get().clearWatches() } + runCatching { History.get().clearSearches() } + } + } + }, + ) { Text("Clear all caches") } Spacer(modifier = Modifier.height(32.dp)) Text( @@ -665,3 +741,31 @@ private fun CategoryRow( Switch(checked = enabled, onCheckedChange = { onToggle() }) } } + +/** + * Compact chip-group row for picking a CacheCap. Label on the left, + * 5 chips on the right. Used four times in the Cache section so the + * shape is consolidated here. + */ +@Composable +private fun CacheCapRow( + label: String, + selected: CacheCap, + onPick: (CacheCap) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CacheCap.entries.forEach { opt -> + FilterChip( + selected = selected == opt, + onClick = { onPick(opt) }, + label = { Text(opt.label) }, + ) + } + } + } +} From c4bf7446c984c69f4e62a0ba439011e356a07f0a Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:36:39 -0700 Subject: [PATCH 56/87] vc=59 fixup: restore MAX_*_HARD const declarations Previous replace_all on 'MAX_WATCHES' over-matched 'MAX_WATCHES_HARD' and produced 'maxWatches()_HARD' which Kotlin parses as garbage. Same for MAX_SEARCHES_HARD, MAX_RESUMES_HARD, MAX_QUERIES_HARD. Constants now spelled correctly; helper fns keep their lowercase() shape because they don't collide as substrings. --- .../com/sulkta/straw/data/HistoryStore.kt | 8 +- .../sulkta/straw/data/ResumePositionsStore.kt | 4 +- .../com/sulkta/straw/data/SearchCacheStore.kt | 4 +- .../feature/feed/FeedRefreshScheduler.kt | 53 ++++++++++++ .../straw/feature/feed/FeedRefreshWorker.kt | 82 +++++++++++++++++++ 5 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 351e9b7b7..9e70c7101 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -38,8 +38,8 @@ private const val KEY_SEARCHES = "searches_v1" * allow truly-uncapped growth that could OOM SP on a hostile import. * Any user-picked cap above this is silently floored to MAX_*_HARD. */ -private const val maxWatches()_HARD = 100_000 -private const val maxSearches()_HARD = 100_000 +private const val MAX_WATCHES_HARD = 100_000 +private const val MAX_SEARCHES_HARD = 100_000 class HistoryStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -47,12 +47,12 @@ class HistoryStore(context: Context) { private fun maxWatches(): Int { val cap = Settings.get().historyWatchesCap.value.value - return cap.coerceAtMost(maxWatches()_HARD) + return cap.coerceAtMost(MAX_WATCHES_HARD) } private fun maxSearches(): Int { val cap = Settings.get().historySearchesCap.value.value - return cap.coerceAtMost(maxSearches()_HARD) + return cap.coerceAtMost(MAX_SEARCHES_HARD) } private val _watches = MutableStateFlow(loadWatches()) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index db4621e98..42ddfc8db 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -41,7 +41,7 @@ private const val KEY_POSITIONS = "positions_v1" * than HistoryStore because resume entries are tiny (~50 bytes each) * vs WatchHistoryItem's ~250 bytes. */ -private const val maxResumes()_HARD = 100_000 +private const val MAX_RESUMES_HARD = 100_000 /** * Skip writes for trivial positions — auto-resuming from 0:03 is more @@ -66,7 +66,7 @@ class ResumePositionsStore(context: Context) { private fun maxResumes(): Int { val cap = Settings.get().resumePositionsCap.value.value - return cap.coerceAtMost(maxResumes()_HARD) + return cap.coerceAtMost(MAX_RESUMES_HARD) } /** diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index 5fad63def..56b0d3575 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -41,7 +41,7 @@ data class SearchCacheEntry( private const val PREFS = "straw_search_cache" private const val KEY = "search_v1" -private const val maxQueries()_HARD = 5000 +private const val MAX_QUERIES_HARD = 5000 private const val MAX_ITEMS_PER_QUERY = 20 class SearchCacheStore(context: Context) { @@ -52,7 +52,7 @@ class SearchCacheStore(context: Context) { val entries: StateFlow> = _entries.asStateFlow() private fun maxQueries(): Int = - Settings.get().searchCacheCap.value.value.coerceAtMost(maxQueries()_HARD) + Settings.get().searchCacheCap.value.value.coerceAtMost(MAX_QUERIES_HARD) /** * Filter out entries older than the configured TTL. Called on every diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt new file mode 100644 index 000000000..9d0624845 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Schedules FeedRefreshWorker via WorkManager based on Settings. + * Called from StrawApp.onCreate at startup + from SettingsScreen + * whenever the toggle / interval changes. + */ + +package com.sulkta.straw.feature.feed + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.sulkta.straw.data.BgFeedRefreshInterval +import com.sulkta.straw.data.Settings +import java.util.concurrent.TimeUnit + +private const val WORK_NAME = "straw-feed-refresh" + +object FeedRefreshScheduler { + fun applyFromSettings(context: Context) { + val s = Settings.get() + val wm = WorkManager.getInstance(context.applicationContext) + if (!s.bgFeedRefreshEnabled.value) { + wm.cancelUniqueWork(WORK_NAME) + return + } + val request = PeriodicWorkRequestBuilder( + s.bgFeedRefreshInterval.value.minutes, + TimeUnit.MINUTES, + ).setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ).build() + wm.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } +} + +private val BgFeedRefreshInterval.minutes: Long + get() = when (this) { + BgFeedRefreshInterval.M30 -> 30 + BgFeedRefreshInterval.H1 -> 60 + BgFeedRefreshInterval.H6 -> 6 * 60 + } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt new file mode 100644 index 000000000..312732be1 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Background subscription-feed refresh. Periodically calls + * uniffi.strawcore.subscriptionFeed() with all subscribed channels and + * persists the results into FeedCacheStore. Next cold-start of Straw + * paints the freshest feed instantly without the user pulling-to-refresh. + * + * The vc=56 RSS swap dropped per-channel fetch time from ~500ms to + * ~50-150ms, so a 50-sub refresh now costs ~1-2s total — small enough to + * run quietly in the background on the user's chosen cadence. + * + * Disabled by default (opt-in via Settings). Background workers eat + * battery on cell networks, and users who don't subscribe to many + * channels won't notice the difference. + */ + +package com.sulkta.straw.feature.feed + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.data.FeedCacheEntry +import com.sulkta.straw.data.Settings +import com.sulkta.straw.data.Subscriptions +import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.util.strawLogI + +class FeedRefreshWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + if (!Settings.get().bgFeedRefreshEnabled.value) return Result.success() + val subs = Subscriptions.get().subs.value + if (subs.isEmpty()) return Result.success() + strawLogI("FeedRefresh", "background tick: ${subs.size} channels") + + // One bulk call via the Rust subscriptionFeed fan-out. Returns + // a flat list; we group by uploaderUrl to rebuild the per- + // channel cache shape FeedCacheStore expects. + val flat = runCatching { + uniffi.strawcore.subscriptionFeed(subs.map { it.url }) + }.getOrNull() ?: return Result.success() + + val now = System.currentTimeMillis() + val grouped: Map = flat + .groupBy { it.uploaderUrl.orEmpty() } + .filterKeys { it.isNotBlank() } + .mapValues { (chUrl, items) -> + FeedCacheEntry( + fetchedAt = now, + items = items.map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader, + uploaderUrl = v.uploaderUrl ?: chUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + uploadDateRelative = v.uploadDateRelative, + ) + }, + ) + } + + if (grouped.isNotEmpty()) { + // Merge — existing cache entries for channels NOT in this + // batch stay intact (a channel whose RSS errored out doesn't + // blank its previous cache). + val before = FeedCache.get().load() + val merged = before.toMutableMap() + grouped.forEach { (k, v) -> merged[k] = v } + FeedCache.get().save(merged) + strawLogI("FeedRefresh", "wrote ${grouped.size} channels to FeedCache") + } + return Result.success() + } +} From aead95f1bc13d0827e7265d068564778ef558e5f Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:38:04 -0700 Subject: [PATCH 57/87] vc=59 cont: wire bg subs refresh + R8 keep + Settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundling the background-refresh worker (originally planned as vc=60) into the same release as cache controls — they're both storage-and-refresh user-facing knobs, ships cleaner together. - StrawApp.onCreate calls FeedRefreshScheduler.applyFromSettings - R8 keep rule for FeedRefreshWorker (same reason as UpdateCheckWorker — WorkManager instantiates via reflection) - Settings UI: 'Auto-refresh subs' toggle (default off) + interval chip-row (30min / 1h / 6h) shown when enabled. Lives in the existing Local cache section since it's the same storage-and-refresh theme. Worker calls uniffi.strawcore.subscriptionFeed which fans out 50 parallel RSS fetches in Rust — 50 subs refreshes in ~1-2s in the background. Writes per-channel into FeedCacheStore so next cold open of Subs paints instantly. --- strawApp/proguard-rules.pro | 1 + .../main/kotlin/com/sulkta/straw/StrawApp.kt | 4 ++ .../com/sulkta/straw/data/SettingsStore.kt | 48 ++++++++++++++++ .../straw/feature/settings/SettingsScreen.kt | 55 +++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro index 97c631f42..fad37e118 100644 --- a/strawApp/proguard-rules.pro +++ b/strawApp/proguard-rules.pro @@ -86,4 +86,5 @@ # renames our UpdateCheckWorker the scheduler enqueues it but the # instantiation fails silently and no checks ever run. -keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; } +-keep class com.sulkta.straw.feature.feed.FeedRefreshWorker { *; } -keep class * extends androidx.work.ListenableWorker { *; } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index c3226623a..a8c0c4794 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -14,6 +14,7 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.util.strawLogW @@ -93,5 +94,8 @@ class StrawApp : Application() { if (Settings.get().autoUpdateCheck.value) { appScope.launch { runUpdateCheck(this@StrawApp) } } + // Background subs feed refresh — opt-in periodic WorkManager + // job that pre-warms FeedCache so cold open paints fresh. + FeedRefreshScheduler.applyFromSettings(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 500c330ed..bda38ba0a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -104,6 +104,18 @@ enum class CacheTtl(val label: String, val days: Int) { val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L } +/** + * How often the background subs-feed-refresh worker polls. Defaults to + * 1h — tighter than that wastes battery without meaningful freshness + * gain (YouTube uploads aren't real-time). Background worker is OFF + * by default; opt-in via Settings. + */ +enum class BgFeedRefreshInterval(val label: String) { + M30("Every 30 minutes"), + H1("Every hour"), + H6("Every 6 hours"), +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_MAX_RES = "max_resolution_v1" @@ -125,6 +137,8 @@ private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1" private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1" private const val KEY_CACHE_SEARCH = "cache_search_v1" private const val KEY_CACHE_TTL = "cache_ttl_v1" +private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1" +private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -270,6 +284,21 @@ class SettingsStore(context: Context) { private val _cacheTtl = MutableStateFlow(loadCacheTtl()) val cacheTtl: StateFlow = _cacheTtl.asStateFlow() + /** + * Background subscription-feed refresh — WorkManager periodic job + * that pre-warms FeedCache so the next cold open paints a fresh + * feed without pull-to-refresh. Off by default; cell-network + * battery cost is the explicit opt-in. + */ + private val _bgFeedRefreshEnabled = MutableStateFlow( + sp.getBoolean(KEY_BG_FEED_REFRESH_ENABLED, false), + ) + val bgFeedRefreshEnabled: StateFlow = _bgFeedRefreshEnabled.asStateFlow() + + private val _bgFeedRefreshInterval = MutableStateFlow(loadBgFeedInterval()) + val bgFeedRefreshInterval: StateFlow = + _bgFeedRefreshInterval.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -404,6 +433,18 @@ class SettingsStore(context: Context) { sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply() } + fun setBgFeedRefreshEnabled(enabled: Boolean) { + if (_bgFeedRefreshEnabled.value == enabled) return + _bgFeedRefreshEnabled.value = enabled + sp.edit().putBoolean(KEY_BG_FEED_REFRESH_ENABLED, enabled).apply() + } + + fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) { + if (_bgFeedRefreshInterval.value == interval) return + _bgFeedRefreshInterval.value = interval + sp.edit().putString(KEY_BG_FEED_REFRESH_INTERVAL, interval.name).apply() + } + private fun loadCap(key: String, default: Int): CacheCap = CacheCap.nearest(sp.getInt(key, default)) @@ -412,6 +453,13 @@ class SettingsStore(context: Context) { return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30 } + private fun loadBgFeedInterval(): BgFeedRefreshInterval { + val name = sp.getString(KEY_BG_FEED_REFRESH_INTERVAL, null) + ?: return BgFeedRefreshInterval.H1 + return BgFeedRefreshInterval.entries.firstOrNull { it.name == name } + ?: BgFeedRefreshInterval.H1 + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 0eeb56246..9f7486c11 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -47,10 +47,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.material3.FilterChip import com.sulkta.straw.BuildConfig import com.sulkta.straw.data.AutoUpdateInterval +import com.sulkta.straw.data.BgFeedRefreshInterval import com.sulkta.straw.data.CacheCap import com.sulkta.straw.data.CacheTtl import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.Resume +import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.util.formatRelativeSince @@ -532,6 +534,59 @@ fun SettingsScreen() { ) } + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Background refresh", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Periodically pre-fetch the subs feed so the next time you " + + "open Straw the latest videos are already there. Off by " + + "default (battery cost on cell).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + val bgEnabled by store.bgFeedRefreshEnabled.collectAsState() + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Auto-refresh subs", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Switch( + checked = bgEnabled, + onCheckedChange = { checked -> + store.setBgFeedRefreshEnabled(checked) + FeedRefreshScheduler.applyFromSettings(context) + }, + ) + } + if (bgEnabled) { + val bgInterval by store.bgFeedRefreshInterval.collectAsState() + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + BgFeedRefreshInterval.entries.forEach { opt -> + FilterChip( + selected = bgInterval == opt, + onClick = { + store.setBgFeedRefreshInterval(opt) + FeedRefreshScheduler.applyFromSettings(context) + }, + label = { Text(opt.label) }, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) Text( "Cache & history limits", From 26c9483b941969e119fa9ff1cc9153967be85798 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 11:59:19 -0700 Subject: [PATCH 58/87] vc=60: storage usage readouts in cache settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each store + Coil image cache shows its actual on-disk byte count next to its cap chip-row. Closes the loop on vc=59's cache controls — users can see what each cap is doing instead of guessing. - StorageUsage.sharedPrefBytes — reads dataDir/shared_prefs/X.xml length directly. Cheap, advisory; not authoritative on Android's internal SP layout but close enough to be useful. - StorageUsage.coilDiskCacheBytes — pulls SingletonImageLoader.get().diskCache?.size, returns 0 if Coil hasn't lazily initialized yet. - StorageUsage.format — KB/MB/GB renderer with 0 -> '—'. Usage snapshot is captured once per Settings entry via remember{} so File.length() doesn't refire on every recomposition. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../straw/feature/settings/SettingsScreen.kt | 70 +++++++++++++++++-- .../com/sulkta/straw/util/StorageUsage.kt | 51 ++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/StorageUsage.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 72b5ffa36..8580bd278 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 59 -const val STRAW_VERSION_NAME = "0.1.0-BS" +const val STRAW_VERSION_CODE = 60 +const val STRAW_VERSION_NAME = "0.1.0-BT" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 9f7486c11..952f9ab77 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -601,10 +601,23 @@ fun SettingsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) + // Sample on-disk usage once per Settings entry — File.length() is + // cheap but we don't need it to recompose on every state change. + // remember keeps the same snapshot for the entire session. + val usage = remember { + object { + val history = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_history") + val resume = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_resume_positions") + val search = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_search_cache") + val feed = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_feed_cache") + val coil = com.sulkta.straw.util.StorageUsage.coilDiskCacheBytes(context) + } + } CacheCapRow( - label = "Watch history", + label = "Watch + search history", selected = store.historyWatchesCap.collectAsState().value, onPick = { store.setHistoryWatchesCap(it) }, + usageBytes = usage.history, ) CacheCapRow( label = "Search history", @@ -615,12 +628,46 @@ fun SettingsScreen() { label = "Resume positions", selected = store.resumePositionsCap.collectAsState().value, onPick = { store.setResumePositionsCap(it) }, + usageBytes = usage.resume, ) CacheCapRow( label = "Search results cache", selected = store.searchCacheCap.collectAsState().value, onPick = { store.setSearchCacheCap(it) }, + usageBytes = usage.search, ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Subs feed cache", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.feed)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Image cache (thumbnails)", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.coil)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Spacer(modifier = Modifier.height(8.dp)) Text( "Cache TTL", @@ -799,17 +846,32 @@ private fun CategoryRow( /** * Compact chip-group row for picking a CacheCap. Label on the left, - * 5 chips on the right. Used four times in the Cache section so the - * shape is consolidated here. + * 5 chips on the right, optional "Used: X KB" suffix to the right + * of the label so the user can see what each cap is doing. */ @Composable private fun CacheCapRow( label: String, selected: CacheCap, onPick: (CacheCap) -> Unit, + usageBytes: Long = 0L, ) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + if (usageBytes > 0L) { + Text( + text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usageBytes)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Row( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/StorageUsage.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/StorageUsage.kt new file mode 100644 index 000000000..30e16b1a2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/StorageUsage.kt @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * On-disk usage helper for the Settings → Storage section. Reads the + * actual .xml file size for each SharedPreferences-backed store + the + * Coil disk-cache size, so the user can see what's eating space rather + * than guessing from cap settings. + * + * All values are best-effort: a missing file (store never written) + * returns 0; permission/IO errors return 0 and log silently. The + * displayed numbers are advisory, not authoritative. + */ + +package com.sulkta.straw.util + +import android.content.Context +import coil3.SingletonImageLoader +import java.io.File + +object StorageUsage { + /** + * Bytes-on-disk for a SharedPreferences file. The Android framework + * writes `/shared_prefs/.xml`. dataDir is + * `context.applicationInfo.dataDir` (the parent of filesDir, + * approximately). + */ + fun sharedPrefBytes(context: Context, prefsName: String): Long { + val dataDir = context.applicationInfo.dataDir ?: return 0L + val f = File(dataDir, "shared_prefs/$prefsName.xml") + return if (f.exists()) f.length() else 0L + } + + /** + * Coil's disk cache total. Returns 0 if Coil hasn't lazily + * initialized a disk cache yet (no images loaded this session). + */ + fun coilDiskCacheBytes(context: Context): Long = runCatching { + SingletonImageLoader.get(context).diskCache?.size ?: 0L + }.getOrDefault(0L) + + /** Human-friendly rendering: "4.2 KB" / "13 MB" / "—" for 0. */ + fun format(bytes: Long): String { + if (bytes <= 0L) return "—" + val kb = bytes / 1024.0 + if (kb < 1024.0) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024.0) return "%.1f MB".format(mb) + return "%.2f GB".format(mb / 1024.0) + } +} From 6cc789a8a08571168ade40e629454d657602b18b Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 12:24:33 -0700 Subject: [PATCH 59/87] vc=61: fix subs feed sort + date display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb caught the regression on vc=60: subs feed only showed LTT + WTYP because vc=56's RSS path emitted raw ISO timestamps in upload_date_relative, but Kotlin's recencyScore() parser only understands 'N units ago' format. Every item tied at MIN_VALUE, sort order went to whichever channel resolved first in the 50-concurrent fan-out — LTT + WTYP just happened to win the race. Fix in feed.rs: parse the RFC3339 published timestamp, compute delta from now, format as 'N second/minute/hour/day/week/month/year ago'. Matches recencyScore's regex exactly. RSS still gives ISO; we convert at the Rust boundary. Standalone RFC3339 parser (no chrono dep) — Howard Hinnant's civil-to-days algo, 30 lines, handles negative years correctly. Display ALSO benefits — UI was showing the raw ISO string ('2026-05-19T13:00:31+00:00') in the channel row. Now reads '7 days ago' like every other YT client. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/feed.rs | 91 ++++++++++++++++++++++- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 8580bd278..9ec76ca08 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 60 -const val STRAW_VERSION_NAME = "0.1.0-BT" +const val STRAW_VERSION_CODE = 61 +const val STRAW_VERSION_NAME = "0.1.0-BU" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index ceaf572b4..2b8416a5d 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -225,10 +225,16 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { thumbnail: thumbnail.clone(), duration_seconds: 0, view_count: 0, - // RSS gives ISO-8601 timestamps. We pass them - // through unchanged — newer-first sorting on - // raw ISO strings is correct. - upload_date_relative: published.clone(), + // RSS gives RFC3339 timestamps. Convert to + // the human-relative format Kotlin's + // recencyScore parser expects ("N units + // ago"). vc=56 was passing the raw ISO + // through, which broke the sort comparator + // — every item tied at MIN_VALUE so the + // feed order was effectively random; LTT + + // WTYP landed at top because they resolved + // first in the fan-out. Caught 2026-05-26. + upload_date_relative: iso_to_relative(&published), }); } in_entry = false; @@ -255,6 +261,83 @@ enum TextTarget { Published, } +/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N +/// units ago". Drops the timezone offset — YT RSS always serves UTC +/// and the granularity is days at most, so a ±14h skew doesn't matter +/// for the relative display. +/// +/// Falls back to the raw string if parsing fails. That keeps the UI +/// readable even on a malformed feed (rare). +fn iso_to_relative(iso: &str) -> String { + let secs = match parse_rfc3339_secs(iso) { + Some(s) => s, + None => return iso.to_string(), + }; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + format_relative(now_secs.saturating_sub(secs)) +} + +fn parse_rfc3339_secs(s: &str) -> Option { + if s.len() < 19 { + return None; + } + let date = s.get(..10)?; + let time = s.get(11..19)?; + if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') { + return None; + } + let mut date_parts = date.split('-'); + let y: i32 = date_parts.next()?.parse().ok()?; + let m: u32 = date_parts.next()?.parse().ok()?; + let d: u32 = date_parts.next()?.parse().ok()?; + let mut time_parts = time.split(':'); + let hh: u32 = time_parts.next()?.parse().ok()?; + let mm: u32 = time_parts.next()?.parse().ok()?; + let ss: u32 = time_parts.next()?.parse().ok()?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 { + return None; + } + let days = civil_to_days(y, m, d); + Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64) +} + +/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard, +/// branch-free, handles negative years correctly. Source: chrono +/// proposal for C++20. +fn civil_to_days(y: i32, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y / 400 } else { (y - 399) / 400 }; + let yoe = (y - era * 400) as u32; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era as i64 * 146_097 + doe as i64 - 719_468 +} + +fn format_relative(age_secs: i64) -> String { + let s = age_secs.max(0); + fn unit(n: i64, name: &str) -> String { + format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" }) + } + if s < 60 { + unit(s, "second") + } else if s < 3_600 { + unit(s / 60, "minute") + } else if s < 86_400 { + unit(s / 3_600, "hour") + } else if s < 604_800 { + unit(s / 86_400, "day") + } else if s < 2_592_000 { + unit(s / 604_800, "week") + } else if s < 31_536_000 { + unit(s / 2_592_000, "month") + } else { + unit(s / 31_536_000, "year") + } +} + /// Strip the namespace prefix off an XML element name. YouTube's feed /// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only /// care about the local part — namespace-vs-local distinguishing From 6775f8252fa5c273b69f5c603ecab36ed86e8801 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 12:31:27 -0700 Subject: [PATCH 60/87] vc=62: audit-fix sprint on playback regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus max-effort audit on vc=53-vc=61 diff caught four interlocked playback bugs. Cobb's 'video bugs are back' likely lived in the intersection of #1+#2 — stale auto-resume seek + no recovery path. BUG-1: setPlayingFrom clamps auto-resume against entry.durationMs. YouTube can replace a video at the same videoId with a shorter cut (live->VOD trim, premiere edit). Without the clamp, setMediaItem seeks past the new end, ExoPlayer fires onPlayerError, NowPlaying clears, surface locks at thumbnail+spinner. Clamp at lookup uses the recorded duration with a 5s safety margin; falls back to 0 when out of range. BUG-2: InlinePlayer adds a Retry button on the playback-error branch. Tapping it nulls playbackError + bumps a retryVersion that re-keys the setPlayingFrom LaunchedEffect. Previously the screen locked into the error message forever (no UI affordance to re-attempt; LaunchedEffect's keys never changed). Bonus protection: the manual retry path avoids the infinite-error-loop risk a NowPlaying-keyed auto-retry would have created. BUG-4: captureResumePosition now gates strictly on STATE_READY. STATE_BUFFERING during a fresh setMediaItem reports the PREVIOUS item's position via currentPosition — the 5s poll was happily writing A's tail position under B's videoId in that window. Next auto-resume would drop the user mid-A on a fresh open of B. BUG-5: onMediaItemTransition falls back to MediaItem.mediaMetadata when Queue.at(idx) is null. Without the fallback, a Queue/controller desync would leave NowPlaying stuck on the previous item forever, freezing controllerOnThisVideo at false and locking the inline player into thumbnail+spinner on the next screen. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../straw/feature/detail/VideoDetailScreen.kt | 30 +++++++++++--- .../straw/feature/player/PlaybackService.kt | 39 ++++++++++++++++--- .../feature/player/StrawMediaController.kt | 15 ++++++- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9ec76ca08..b1d852b9b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 61 -const val STRAW_VERSION_NAME = "0.1.0-BU" +const val STRAW_VERSION_CODE = 62 +const val STRAW_VERSION_NAME = "0.1.0-BV" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 447c7f79e..02c86e3b9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -741,8 +742,14 @@ private fun InlinePlayer( // Push the resolved stream into the shared controller if it isn't // already playing this URL. We don't kick off a new fetch — the // outer VideoDetailScreen already called vm.load(streamUrl). + // + // retryVersion lets the user manually re-fire setPlayingFrom after + // a playback error. Without it, the screen used to lock into the + // thumbnail+spinner branch once NowPlaying.clear() fired from + // onPlayerError. vc=62 audit BUG-2. val resolved = state.resolved - LaunchedEffect(controller, resolved, streamUrl) { + var retryVersion by remember(streamUrl) { mutableIntStateOf(0) } + LaunchedEffect(controller, resolved, streamUrl, retryVersion) { val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect // Optimization, not safety. claim() guards the race. @@ -792,11 +799,24 @@ private fun InlinePlayer( color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp), ) - playbackError != null -> Text( - "playback error: $playbackError", - color = MaterialTheme.colorScheme.error, + playbackError != null -> Column( modifier = Modifier.padding(16.dp), - ) + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = { + // Clear the error AND nudge the LaunchedEffect to + // re-attempt setPlayingFrom. vc=62 audit BUG-2 — + // without this the screen used to lock on the + // error forever after NowPlaying.clear(). + playbackError = null + retryVersion += 1 + }) { Text("Retry") } + } resolved?.isPlayable != true -> Text( "no playable stream", color = Color.White, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 692aebd04..ea0e89f50 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -158,12 +158,32 @@ class PlaybackService : MediaSessionService() { override fun onMediaItemTransition(item: MediaItem?, reason: Int) { if (item == null) return val idx = player.currentMediaItemIndex - val queued = Queue.at(idx) ?: return - NowPlaying.claim(queued) - if (queued.segments.isEmpty()) { - val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl) - if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId) + val queued = Queue.at(idx) + if (queued != null) { + NowPlaying.claim(queued) + if (queued.segments.isEmpty()) { + val videoId = + com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl) + if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId) + } + return } + // Queue desync — MediaItem was added by a path that + // bypassed enqueueInternal, OR the queue was cleared + // while a transition was pending. Fall back to the + // MediaItem's own metadata so NowPlaying doesn't stay + // stuck on the previous video forever (would freeze + // VideoDetail's controllerOnThisVideo guard at false + // and lock the inline player into thumbnail+spinner). + // vc=62 audit BUG-5. + val uri = item.localConfiguration?.uri?.toString() ?: return + val fallback = NowPlayingItem( + streamUrl = uri, + title = item.mediaMetadata.title?.toString().orEmpty(), + uploader = item.mediaMetadata.artist?.toString().orEmpty(), + thumbnail = item.mediaMetadata.artworkUri?.toString(), + ) + NowPlaying.claim(fallback) } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -205,10 +225,17 @@ class PlaybackService : MediaSessionService() { * ResumePositionsStore. Bails on idle/ended states and unknown * durations (live streams). The store itself enforces minimum- * position + near-end-clear thresholds. + * + * Gates STRICTLY on STATE_READY. STATE_BUFFERING during a fresh + * setMediaItem still reports the PREVIOUS item's position via + * currentPosition until prepare finishes and the new timeline + * lands — without the gate we'd record A's tail position under + * B's videoId and auto-resume the user mid-A on next open. + * vc=62 audit BUG-4. */ private fun captureResumePosition(player: Player) { val state = player.playbackState - if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) return + if (state != Player.STATE_READY) return val item = NowPlaying.current.value ?: return val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return val pos = player.currentPosition diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 923dcd095..8e277eac2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -121,9 +121,22 @@ fun Player.setPlayingFrom( // an app update / process death. The store skips trivial // positions and clears near-end so we don't auto-resume to 0:03 // or to the credits. + // + // Clamp the resume position against the RECORDED duration with a + // safety margin. vc=62 audit BUG-1: YouTube can replace a video + // at the same videoId with a shorter cut (live→VOD trim, premiere + // edit, channel replace) — without the clamp, setMediaItem seeks + // past the new end, ExoPlayer fires onPlayerError, the screen + // ends up stuck on the thumbnail+spinner (BUG-2 cascade). val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) { val videoId = extractYtVideoId(streamUrl) - videoId?.let { Resume.get().get(it)?.positionMs } ?: 0L + val saved = videoId?.let { Resume.get().get(it) } + if (saved == null) { + 0L + } else { + val safeCeiling = saved.durationMs - 5_000L + if (saved.positionMs in 1L..safeCeiling) saved.positionMs else 0L + } } else { startPositionMs } From 7bd2740055826fe64b5b488f00502252c6e95a25 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 12:47:31 -0700 Subject: [PATCH 61/87] vc=63: fix stale-state nav-bug (new page shows, old video plays) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb reproduced 2026-05-26: clicking video B from detail A's related section opens detail B (title, description correct), minibar/media notification show A's title, and AUDIO plays A. The bug everyone was hitting as 'video bugs are back'. Root cause — VideoDetailViewModel is activity-scoped, so navigating A→B shows ONE composition frame with the previous video's state before vm.load(B)'s reset propagates. During that frame: 1. VideoDetailScreen body runs with streamUrl=B but state.detail=A and state.resolved=A's playback URLs (stale). 2. InlinePlayer is called with title=A, streamUrl=B, resolved=A's. 3. Its LaunchedEffect launches a coroutine. Body is synchronous (no suspend), runs to completion before cancellation can interrupt. 4. setPlayingFrom(streamUrl=B, resolved=A's URLs) fires. claim() succeeds → NowPlaying = {streamUrl=B, title=A's title}. setMediaItem with A's playback URIs → player loads + plays A. 5. State reset propagates. InlinePlayer disposes. 6. After vm.load completes with B's data, InlinePlayer recomposes with B's resolved. Its NEW LaunchedEffect fires. The check 'NowPlaying.streamUrl == streamUrl' returns true (because step 4 already stamped streamUrl=B). RETURN EARLY. setPlayingFrom(B) NEVER fires with the correct B data. Fix — add a loadedUrl field to VideoDetailUiState that tracks which streamUrl the current detail/resolved actually belong to. Gate VideoDetailScreen's player composition on state.loadedUrl == streamUrl, so the stale-state frame can't fire setPlayingFrom with mismatched data. vm.load sets loadedUrl in the initial reset AND the success/error updates — every state transition carries the URL that owns it. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../straw/feature/detail/VideoDetailScreen.kt | 9 +++++++++ .../straw/feature/detail/VideoDetailViewModel.kt | 14 +++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index b1d852b9b..409599050 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 62 -const val STRAW_VERSION_NAME = "0.1.0-BV" +const val STRAW_VERSION_CODE = 63 +const val STRAW_VERSION_NAME = "0.1.0-BW" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 02c86e3b9..4d4e67411 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -258,6 +258,15 @@ fun VideoDetailScreen( else -> { val d = state.detail ?: return@Column + // Guard against vm's activity-scoped staleness — on a + // fresh navigation A → B, the shared VM still holds + // A's detail/resolved for one composition frame before + // vm.load(B)'s reset propagates. Without this gate, the + // InlinePlayer's LaunchedEffect would fire with + // streamUrl=B but resolved=A's URLs and play A under + // B's chrome (Cobb-reported 2026-05-26: detail page + // shows new video, audio is the old one). + if (state.loadedUrl != streamUrl) return@Column // Player surface — edge-to-edge, NewPipe/YouTube style. // Lives outside the 16dp horizontal padding so the // thumbnail fills the screen width with no gutters. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index f20864efa..76a12d77e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -93,6 +93,16 @@ data class VideoDetailUiState( val error: String? = null, /** Raw extractor result — kept around for the Download dialog. */ val streamInfo: uniffi.strawcore.StreamInfo? = null, + /** + * Tracks which URL the current `detail`/`resolved` belong to. + * vm is activity-scoped, so a fresh navigation to detail B sees + * the PREVIOUS video's state for one composition frame before + * vm.load(B) clears it. Without this field, the InlinePlayer's + * setPlayingFrom would fire with streamUrl=B but resolved=A's + * playback URLs — claiming NowPlaying with B's streamUrl but + * playing A's video under it. vc=63 audit. + */ + val loadedUrl: String? = null, ) class VideoDetailViewModel : ViewModel() { @@ -127,7 +137,7 @@ class VideoDetailViewModel : ViewModel() { } inFlight?.cancel() loadedUrl = streamUrl - _ui.update { VideoDetailUiState(loading = true) } + _ui.update { VideoDetailUiState(loading = true, loadedUrl = streamUrl) } inFlight = viewModelScope.launch { try { // strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap. @@ -273,6 +283,7 @@ class VideoDetailViewModel : ViewModel() { ), resolved = resolved, streamInfo = info, + loadedUrl = streamUrl, ) } } catch (t: Throwable) { @@ -284,6 +295,7 @@ class VideoDetailViewModel : ViewModel() { error = com.sulkta.straw.util.LogDump.scrubLine( t.message ?: t.javaClass.simpleName, ), + loadedUrl = streamUrl, ) } } From 944fbd4335be6d6dbbd296811e1d31af6b412381 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 13:06:57 -0700 Subject: [PATCH 62/87] =?UTF-8?q?vc=3D64:=20UX=20polish=20=E2=80=94=20chip?= =?UTF-8?q?=20wrap=20+=20RelatedRow=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues Cobb caught on the vc=63 walkthrough: (1) Subscription chip names wrapped to two lines mid-word at 80dp chip width: 'NoCopyrightS / ounds', 'DEFCONConfe / rence', 'Practica / Engineer...'. Switched to maxLines=1 + ellipsis + center-align. 'NoCopyrigh…' reads cleaner than the broken wrap. (2) Related + More-from-channel rows showed an empty metadata line under the title because the buildString started with item.uploader (empty for channelInfo-sourced rows — channel pages omit the uploader name from each card since it's implicit). Switched to a leading-separator pattern that gracefully composes whatever pieces are populated: 'uploader · views · date', 'views · date', 'date', etc., and hides the line entirely when nothing's available. Date was also never rendered before — channelInfo gives it but RelatedRow ignored it. Now visible everywhere. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 9 +++- .../straw/feature/detail/VideoDetailScreen.kt | 41 +++++++++++++------ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 409599050..df9a9cfd5 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 63 -const val STRAW_VERSION_NAME = "0.1.0-BW" +const val STRAW_VERSION_CODE = 64 +const val STRAW_VERSION_NAME = "0.1.0-BX" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 322b7bd4f..06acdf2cb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -587,11 +587,18 @@ private fun SubChip( ) } Spacer(modifier = Modifier.height(4.dp)) + // Single line + ellipsis instead of maxLines=2. The 80dp chip + // width breaks the prior 2-line wrap mid-word ("NoCopyrightS + // / ounds", "DEFCONConfe / rence") — uglier than a clean + // "NoCopyrigh…". Centered text alignment so the ellipsis + // sits over the chip's icon column. vc=64. Text( text = ch.name, style = MaterialTheme.typography.labelSmall, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 4d4e67411..87f9e2b3e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -709,19 +709,34 @@ private fun RelatedRow( overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(2.dp)) - Text( - text = buildString { - append(item.uploader) - if (item.viewCount > 0) { - append(" · ") - append(formatViews(item.viewCount)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // Build the metadata line from whatever's available. + // channelInfo-sourced items (More from channel) come back + // with uploader="" because the channel page doesn't repeat + // the uploader name on each row — it's implicit. Skip + // empty pieces with the leading-separator dance so we + // never end up with " · viewCount" or trailing dots. + // vc=64 — Cobb caught the empty metadata line on + // More-from-channel rows. + val meta = buildString { + if (item.uploader.isNotBlank()) append(item.uploader) + if (item.viewCount > 0) { + if (isNotEmpty()) append(" · ") + append(formatViews(item.viewCount)) + } + if (item.uploadDateRelative.isNotBlank()) { + if (isNotEmpty()) append(" · ") + append(item.uploadDateRelative) + } + } + if (meta.isNotEmpty()) { + Text( + text = meta, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } From 7156208c3c3c391040182b6d4e9424fd6359f458 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 13:09:23 -0700 Subject: [PATCH 63/87] vc=65: metadata consistency across ChannelScreen + SearchScreen rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vc=64 fixed RelatedRow's empty-metadata bug. ChannelVideoRow and ResultRow had the same shape problem AND duplicated duration: - ChannelVideoRow: showed 'N views · 0:42' which doubled with the VideoThumbnail's bottom-right duration badge. Stripped the duration text, added uploadDateRelative. Now reads 'N views · 2 days ago' matching YT's channel page format. - ResultRow: same duplicate-duration. Same fix. Search results now show 'N views · 2 days ago' under the uploader line. All four video-row composables (FeedRow, RelatedRow, ChannelVideoRow, ResultRow) now use the same leading-separator buildString pattern: 'piece [· piece]*' that gracefully composes whatever fields are populated. No more empty metadata lines, no more duplicate duration. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +-- .../straw/feature/channel/ChannelScreen.kt | 32 ++++++++++++------- .../straw/feature/search/SearchScreen.kt | 20 ++++++++---- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index df9a9cfd5..75e693f5e 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 64 -const val STRAW_VERSION_NAME = "0.1.0-BX" +const val STRAW_VERSION_CODE = 65 +const val STRAW_VERSION_NAME = "0.1.0-BY" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index bca2e6641..1618fc2a9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -205,18 +205,26 @@ private fun ChannelVideoRow( overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(2.dp)) - Text( - text = buildString { - if (item.viewCount > 0) append("${formatCount(item.viewCount)} views") - if (item.durationSeconds > 0) { - if (isNotEmpty()) append(" · ") - append(formatDuration(item.durationSeconds)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + // Don't repeat duration here — VideoThumbnail's + // bottom-right badge already shows it. Add the upload + // date so the row reads 'N views · 2 days ago' the way + // YT renders it. vc=65 — Cobb caught the duplicate + // duration + missing date on the channel page. + val meta = buildString { + if (item.viewCount > 0) append("${formatCount(item.viewCount)} views") + if (item.uploadDateRelative.isNotBlank()) { + if (isNotEmpty()) append(" · ") + append(item.uploadDateRelative) + } + } + if (meta.isNotEmpty()) { + Text( + text = meta, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 4f4adebfa..17d804b12 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -247,13 +247,21 @@ private fun ResultRow( else Modifier.padding(vertical = 4.dp), ) - if (item.viewCount > 0 || item.durationSeconds > 0) { + // Drop the duration here — VideoThumbnail's badge already + // renders it on the bottom-right of the thumbnail. Add the + // upload date instead so search results read like YT's + // own format. vc=65 — caught with the + // channel-page + related-row consistency pass. + val meta = buildString { + if (item.viewCount > 0) append(formatViews(item.viewCount)) + if (item.uploadDateRelative.isNotBlank()) { + if (isNotEmpty()) append(" · ") + append(item.uploadDateRelative) + } + } + if (meta.isNotEmpty()) { Text( - text = buildString { - if (item.viewCount > 0) append(formatViews(item.viewCount)) - if (item.viewCount > 0 && item.durationSeconds > 0) append(" · ") - if (item.durationSeconds > 0) append(formatDuration(item.durationSeconds)) - }, + text = meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, From dd151e322dea70f097d06578a9845332c5318b02 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 13:40:26 -0700 Subject: [PATCH 64/87] =?UTF-8?q?vc=3D66:=20hybrid=20feed=20backfill=20?= =?UTF-8?q?=E2=80=94=20RSS-fast=20+=20streamInfo-complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb asked for views + durations back in the subs feed without giving up the 5-10× RSS speedup vc=56 bought. Hybrid path: 1. Rust wrapper — new enrich_feed_item(video_url) -> EnrichedFeedMetadata { view_count, duration_seconds }. Thin wrapper around stream_info that discards the heavy play-URL payload. Future opt: parse watch-page HTML JSON state directly to skip JS deobf entirely. ~150 lines of pluck logic, punted. 2. EnrichmentStore — new SharedPreferences-lite store keyed by videoId, value Enrichment(viewCount, durationSeconds, fetchedAt). Bound to Settings.cacheTtl for staleness. Hard cap 5000 entries with oldest-eviction. 3. SubscriptionFeedViewModel — after the RSS refresh paints, enrichVisibleItems() fans out enrichFeedItem for the first 30 items (skipping any already enriched fresh). Bounded at 8 wide so we don't hammer YT; each call ~500ms full streamInfo so 30 items in ~2s. Runs on StrawApp.globalScope so a refresh-cancel doesn't kill the in-flight enrichment. mergeFromCache overlays the enrichment via .withEnrichment() so RSS rows pick up viewCount + durationSeconds the moment they land. The Enrichment store's StateFlow.value is read on every merge call; the enrichment-complete handler triggers a _ui.update that re-merges. Net behavior: feed paints instantly from RSS (no view/duration), ~2s later the visible top-N populate with full metadata. Cached forever (or until TTL/cap). Subsequent opens read straight from EnrichmentStore. StrawApp.onCreate inits the new store alongside the existing SP-backed ones. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/feed.rs | 30 +++++ .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../com/sulkta/straw/data/EnrichmentStore.kt | 118 ++++++++++++++++++ .../feature/feed/SubscriptionFeedViewModel.kt | 85 ++++++++++++- 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 75e693f5e..f7ebda3b7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 65 -const val STRAW_VERSION_NAME = "0.1.0-BY" +const val STRAW_VERSION_CODE = 66 +const val STRAW_VERSION_NAME = "0.1.0-BZ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 2b8416a5d..259dee238 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -25,6 +25,36 @@ const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id="; const MAX_CONCURRENT: usize = 50; const PER_CHANNEL_TIMEOUT_S: u64 = 8; +/// Hybrid-backfill metadata: just the two fields RSS doesn't return +/// (view count + duration). Kotlin calls this lazily for visible feed +/// items after the RSS-fed paint to fill in the gaps that +/// channel_feed_rss leaves empty. +/// +/// vc=66 — built specifically so the subs feed can show 'N views · +/// X duration' the way YT does, without paying the full channel_info +/// page-scrape cost on initial paint. The underlying stream_info IS +/// heavier than we'd like (~500ms each, runs JS deobf for play URLs +/// we'll discard) — future opt would be to parse the watch-page HTML +/// JSON state directly for just these two fields. ~100ms savings per +/// call but ~150 lines of HTML/JSON pluck logic. Punted until needed. +#[derive(Debug, Clone, uniffi::Record)] +pub struct EnrichedFeedMetadata { + pub view_count: i64, + pub duration_seconds: i64, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn enrich_feed_item( + video_url: String, +) -> Result { + crate::runtime::ensure_initialized(); + let info = crate::stream::stream_info(video_url).await?; + Ok(EnrichedFeedMetadata { + view_count: info.view_count, + duration_seconds: info.duration_seconds, + }) +} + /// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out /// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms /// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index a8c0c4794..ec492835d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -7,6 +7,7 @@ package com.sulkta.straw import android.app.Application import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.data.FeedEnrichment import com.sulkta.straw.data.History import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Resume @@ -72,6 +73,7 @@ class StrawApp : Application() { Subscriptions.init(this) Playlists.init(this) Resume.init(this) + FeedEnrichment.init(this) // vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache // (~150 KB) JSON-decode at construction. Stash the // applicationContext eagerly (cheap) so `get()` is callable diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt new file mode 100644 index 000000000..c18448bfd --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Subs-feed enrichment cache. RSS gives us title/url/thumbnail/date + * fast but no view count or duration. After a feed refresh paints + * from RSS, SubscriptionFeedViewModel fans out lightweight + * uniffi.strawcore.enrichFeedItem() calls for the top visible items + * and stashes the results here. mergeFromCache overlays the + * enrichment onto each StreamItem at render time so the row shows + * 'N views · X duration' once available. + * + * Storage: SharedPreferences-lite, single JSON blob keyed by videoId. + * TTL bound to Settings.cacheTtl so enrichments age out alongside the + * rest of the cache. Hard cap at MAX_ENRICHMENTS to bound disk + + * memory. + */ + +package com.sulkta.straw.data + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class Enrichment( + val viewCount: Long, + val durationSeconds: Long, + val fetchedAt: Long, +) + +private const val PREFS = "straw_feed_enrichment" +private const val KEY = "enrichments_v1" + +/** + * Hard ceiling — keeps the JSON blob below ~250 KB even at the cap + * (50 bytes/entry × 5000 = 250 KB). The user-facing cap doesn't tie + * to this; enrichment is "cache" not "user data." + */ +private const val MAX_ENRICHMENTS = 5_000 + +class EnrichmentStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + private val _entries = MutableStateFlow(load()) + val entries: StateFlow> = _entries.asStateFlow() + + /** + * Return a fresh enrichment for this videoId, or null when missing + * or aged out per Settings.cacheTtl. Forever-TTL never expires. + */ + fun get(videoId: String): Enrichment? { + if (videoId.isBlank()) return null + val e = _entries.value[videoId] ?: return null + val ttl = Settings.get().cacheTtl.value + if (ttl.isForever) return e + val cutoff = System.currentTimeMillis() - ttl.ms + return if (e.fetchedAt >= cutoff) e else null + } + + fun put(videoId: String, viewCount: Long, durationSeconds: Long) { + if (videoId.isBlank()) return + // Don't write all-zero entries — that's failure not data, and + // would waste a slot the cap could spend on a real hit. + if (viewCount <= 0L && durationSeconds <= 0L) return + val entry = Enrichment( + viewCount = viewCount, + durationSeconds = durationSeconds, + fetchedAt = System.currentTimeMillis(), + ) + val before = _entries.value + val next = _entries.updateAndGet { current -> + val withEntry = current + (videoId to entry) + if (withEntry.size > MAX_ENRICHMENTS) { + withEntry.entries + .sortedByDescending { it.value.fetchedAt } + .take(MAX_ENRICHMENTS) + .associate { it.key to it.value } + } else { + withEntry + } + } + if (next !== before) { + sp.edit().putString(KEY, json.encodeToString(next)).apply() + } + } + + fun clear() { + _entries.updateAndGet { emptyMap() } + sp.edit().putString(KEY, json.encodeToString(emptyMap())).apply() + } + + private fun load(): Map = runCatching { + val s = sp.getString(KEY, null) ?: return emptyMap() + json.decodeFromString>(s) + }.getOrDefault(emptyMap()) +} + +object FeedEnrichment { + @Volatile private var instance: EnrichmentStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = EnrichmentStore(context.applicationContext) + } + } + } + + fun get(): EnrichmentStore = instance + ?: error("EnrichmentStore not initialized — call FeedEnrichment.init(context)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 302d898b4..eb393c851 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -19,8 +19,10 @@ package com.sulkta.straw.feature.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.Enrichment import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.FeedCacheEntry +import com.sulkta.straw.data.FeedEnrichment import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem @@ -191,13 +193,19 @@ class SubscriptionFeedViewModel : ViewModel() { .awaitAll() } pruneCacheToSubs(channels) + val freshItems = mergeFromCache(channels) _ui.update { SubscriptionFeedUiState( loading = false, - items = mergeFromCache(channels), + items = freshItems, lastFetchedAt = System.currentTimeMillis(), ) } + // vc=66 — hybrid backfill. RSS-fed items have + // viewCount=0 + durationSeconds=0; kick a bounded + // background job that calls enrichFeedItem for the + // top items and pumps a fresh _ui emit when done. + enrichVisibleItems(freshItems) // Persist what we just freshened. Off the main thread — // JSON encode on 30 subs * 30 items is small but not // free, and SharedPreferences.apply is async anyway. @@ -283,7 +291,15 @@ class SubscriptionFeedViewModel : ViewModel() { // Pre-compute recencyScore once per item — vc=35 audit // MED-Q15: sortedWith's comparator was invoking the regex // twice per pair, so ~1800 regex matches on a 900-item merge. + // + // vc=66 — overlay FeedEnrichment data on each item so RSS-fed + // rows (viewCount=0, durationSeconds=0) get backfilled with + // metadata fetched by the background enrichment job below. + // Pure read of the enrichment store; the enrichment write + // path triggers a fresh _ui emit. + val enrichments = FeedEnrichment.get().entries.value return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() } + .map { it.withEnrichment(enrichments) } .map { it to it.recencyScore() } .sortedWith( compareByDescending> { it.second } @@ -293,6 +309,73 @@ class SubscriptionFeedViewModel : ViewModel() { .map { it.first } } + /** + * Background enrichment: pulls viewCount + durationSeconds for the + * top-N freshly-merged items via the lightweight + * uniffi.strawcore.enrichFeedItem endpoint. Bounded parallel + * (8-wide) — each call is ~500ms full streamInfo, so 30 items + * complete in ~2s. Skipped per-item when FeedEnrichment already + * has a fresh hit (TTL controlled by Settings.cacheTtl). + * + * Runs OFF viewModelScope so a refresh-cancel doesn't kill an + * enrichment that's almost done — the background fill is for + * NEXT-open paint, no rush. Uses StrawApp.globalScope. + */ + private fun enrichVisibleItems(items: List) { + val take = items.take(ENRICH_HEAD_COUNT) + .filter { it.viewCount <= 0L && it.durationSeconds <= 0L } + if (take.isEmpty()) return + com.sulkta.straw.StrawApp.globalScope.launch { + val gate = Semaphore(ENRICH_PARALLELISM) + coroutineScope { + take.map { item -> + async { + gate.withPermit { + val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.url) + ?: return@withPermit + if (FeedEnrichment.get().get(videoId) != null) return@withPermit + val md = runCatchingCancellable { + withContext(Dispatchers.IO) { + uniffi.strawcore.enrichFeedItem(item.url) + } + }.getOrNull() ?: return@withPermit + FeedEnrichment.get().put( + videoId, + md.viewCount, + md.durationSeconds, + ) + } + } + }.awaitAll() + } + // Pump a fresh emit so the UI picks up the overlay. + withContext(Dispatchers.Main) { + val channels = Subscriptions.get().subs.value + _ui.update { it.copy(items = mergeFromCache(channels)) } + } + } + } + + private val ENRICH_HEAD_COUNT = 30 + private val ENRICH_PARALLELISM = 8 + + /** + * Apply an enrichment overlay to a StreamItem. Only fills fields + * that RSS left empty — if the source already had non-zero values + * (e.g. a channelInfo path populated them) we don't clobber. + */ + private fun StreamItem.withEnrichment( + enrichments: Map, + ): StreamItem { + if (viewCount > 0L && durationSeconds > 0L) return this + val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(url) ?: return this + val e = enrichments[videoId] ?: return this + return copy( + viewCount = if (viewCount > 0L) viewCount else e.viewCount, + durationSeconds = if (durationSeconds > 0L) durationSeconds else e.durationSeconds, + ) + } + /** * Clear in-memory cache. Called from Settings when the user flips * off the local-cache toggle — disk wipe via FeedCacheStore.clear() From 796244e0655115907ed6c9d056c0a67bd4b6bd2b Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 15:32:47 -0700 Subject: [PATCH 65/87] vc=67: fix subs feed scroll jank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LazyColumn items() now keyed by url so pagination doesn't re-key every row from scratch when visibleCount jumps. The displayed page slice is remembered so SubsPane doesn't reallocate the take() ArrayList on every recomposition. ThumbnailProgressOverlay switched from collectAsStateWithLifecycle to plain collectAsState — the lifecycle wrapper added a DisposableEffect per call site, which adds up across the ~30 visible rows and was contributing to scroll hitch. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../src/main/kotlin/com/sulkta/straw/StrawHome.kt | 11 +++++++++-- .../sulkta/straw/feature/player/ThumbnailProgress.kt | 10 ++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index f7ebda3b7..5e3d33135 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 66 -const val STRAW_VERSION_NAME = "0.1.0-BZ" +const val STRAW_VERSION_CODE = 67 +const val STRAW_VERSION_NAME = "0.1.0-CA" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 06acdf2cb..94ac9fe69 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -323,7 +323,11 @@ private fun SubsPane( visibleCount = PAGE_SIZE } } - val displayed = filteredItems.take(visibleCount) + // remember the page-slice so we don't allocate a new ArrayList on + // every recomposition (scroll hitch vc=67). + val displayed = remember(filteredItems, visibleCount) { + filteredItems.take(visibleCount) + } val hasMore = filteredItems.size > visibleCount Column { @@ -442,7 +446,10 @@ private fun SubsPane( state = listState, contentPadding = rememberBottomContentPadding(), ) { - items(displayed) { item -> + items( + items = displayed, + key = { it.url }, + ) { item -> FeedRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt index bc4bd4e4f..26daa53f0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt @@ -25,13 +25,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.ProgressBarFillColor @@ -51,7 +51,13 @@ import com.sulkta.straw.util.formatDuration @Composable fun BoxScope.ThumbnailProgressOverlay(videoId: String?) { if (videoId.isNullOrBlank()) return - val positions by Resume.get().positions.collectAsStateWithLifecycle() + // Plain collectAsState — collectAsStateWithLifecycle adds a + // DisposableEffect for lifecycle observation per call site, which + // adds up across 30 visible LazyColumn rows and contributes to + // scroll jank (vc=67). The Lifecycle pause optimization doesn't + // matter for a foreground feed that's only collected while the + // composable is on screen anyway. + val positions by Resume.get().positions.collectAsState() val entry = positions[videoId] ?: return if (entry.durationMs <= 0L) return val fraction = (entry.positionMs.toFloat() / entry.durationMs.toFloat()) From c960a1f424ea044e5f32b09e388f49eaf1b4e46b Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 20:53:25 -0700 Subject: [PATCH 66/87] vc=68: audit-fix sprint round 1 (11 HIGH + MED batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block B — enrichment lifecycle drift: * SubscriptionFeedViewModel tracks enrichJob, cancelled in refresh + clearInMemoryCache so spam-refresh and cache-toggle no longer leave a globalScope coroutine writing to a destroyed _ui * Enrich now runs on viewModelScope, channels snapshotted at job start so the terminal merge doesn't read a stale subs list * mergeFromCache moved off Main on both the refresh path AND the init-hydration path — 750-item flatMap+sort+regex no longer blocks the UI thread * VideoDetailViewModel dual loadedUrl bookkeeping collapsed to the UiState field only; the rejected-URL path also stamps loadedUrl so the gate reads coherently Block A — auto-update authenticity: * AppUpdateClient pins the fdroid.sulkta.com leaf SPKI + the Let's Encrypt E7 intermediate via OkHttp CertificatePinner * file.name accepted only when matching ^/[A-Za-z0-9._-]+\.apk$ * versionCode clamped to (0, 10_000_000] before we trust the 'update available' notification — a hostile index can no longer pin us to MAX_VALUE Block C — captureResumePosition perf: * ResumePositionsStore.record short-circuits when the existing entry matches position+duration so the 5s poll's before !== next guard actually skips the SP write * JSON encode + SP write off Main via globalScope IO Block D — Rust feed.rs hardening: * Shared reqwest Client via OnceLock — 50 channels no longer pay 50 TLS handshakes * Response body capped at 2 MiB via bytes_stream — adversarial feeds can't OOM the JVM * parse_rss returns partial results on quick-xml errors instead of nuking everything already parsed * extract_channel_id widened (m./www./http(s)?/trailing path) and validates exact 24-char UC<22 base64-ish> * Skip entries with empty title/published * iso_to_relative future dates → 'just now' (clock skew no longer pins items to top) * civil_to_days year clamp 1970..=2200 before the i64 arithmetic * Redirect chain capped at 3 * Dropped the broken lexicographic sort on upload_date_relative * Cap parsed entries at 50 per channel MED batch: * ThumbnailProgressOverlay uses derivedStateOf so only rows whose specific entry changed recompose on the 5s positions tick * EnrichmentStore.put short-circuits on identical view+duration so re-enrich within TTL doesn't write SP * EnrichmentStore.load prunes TTL-expired entries on hydration * FeedRefreshWorker distinguishes transient (Result.retry) from parse (Result.success) failures * WorkManager interval coerceAtLeast(15L) on both schedulers --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/feed.rs | 197 ++++++++++++++---- .../com/sulkta/straw/data/EnrichmentStore.kt | 20 +- .../sulkta/straw/data/ResumePositionsStore.kt | 42 +++- .../feature/detail/VideoDetailViewModel.kt | 28 ++- .../feature/feed/FeedRefreshScheduler.kt | 4 +- .../straw/feature/feed/FeedRefreshWorker.kt | 18 +- .../feature/feed/SubscriptionFeedViewModel.kt | 66 ++++-- .../straw/feature/player/ThumbnailProgress.kt | 20 +- .../straw/feature/update/AppUpdateClient.kt | 63 +++++- .../straw/feature/update/UpdateScheduler.kt | 6 +- 11 files changed, 385 insertions(+), 83 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 5e3d33135..1b4ad26a7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 67 -const val STRAW_VERSION_NAME = "0.1.0-CA" +const val STRAW_VERSION_CODE = 68 +const val STRAW_VERSION_NAME = "0.1.0-CB" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 259dee238..395b66840 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -13,6 +13,7 @@ // the full stream_info path to fetch the rich metadata when actually // needed. +use std::sync::OnceLock; use std::time::Duration; use futures::stream::{self, StreamExt}; @@ -24,6 +25,23 @@ use crate::search::SearchItem; const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id="; const MAX_CONCURRENT: usize = 50; const PER_CHANNEL_TIMEOUT_S: u64 = 8; +/// Cap on the body bytes we'll read for a single RSS fetch. Real YT +/// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while +/// blocking a hostile or compromised host from streaming GB-scale +/// bodies into JVM memory inside the 8s timeout. Round-67 audit +/// rust-HIGH-5. +const RSS_MAX_BYTES: usize = 2 * 1024 * 1024; +/// Cap on parsed entries per channel — RSS normally returns 15. +/// 50 leaves headroom for one-off legitimate variance; anything +/// past that is a sign the feed isn't what we expect. +/// Round-67 audit rust-MED-6. +const RSS_MAX_ENTRIES: usize = 50; +/// Year range we trust civil-to-days math for. Strawcore RSS only +/// emits real-world recent uploads; clamping here turns adversarial +/// year fields into a parse failure rather than i64 overflow. +/// Round-67 audit rust-CRIT-1. +const YEAR_MIN: i32 = 1970; +const YEAR_MAX: i32 = 2200; /// Hybrid-backfill metadata: just the two fields RSS doesn't return /// (view count + duration). Kotlin calls this lazily for visible feed @@ -55,6 +73,28 @@ pub async fn enrich_feed_item( }) } +/// Shared reqwest Client — DNS resolver + TLS keepalive + connection +/// pool live here so a 50-channel fan-out reuses one pool instead of +/// paying 50 handshakes. Round-67 audit rust-HIGH-4. +static RSS_CLIENT: OnceLock = OnceLock::new(); + +fn rss_client() -> Result<&'static Client, StrawcoreError> { + if let Some(c) = RSS_CLIENT.get() { + return Ok(c); + } + let client = Client::builder() + .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) + .user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")")) + // Cap redirect chains so a misconfigured/hostile feed can't + // spin a server out of our 8s budget. Round-67 audit rust-LOW-8. + .redirect(reqwest::redirect::Policy::limited(3)) + .build() + .map_err(|e| StrawcoreError::Extractor { + msg: format!("http client build: {e}"), + })?; + Ok(RSS_CLIENT.get_or_init(|| client)) +} + /// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out /// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms /// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh @@ -65,14 +105,8 @@ pub async fn channel_feed_rss( ) -> Result, StrawcoreError> { crate::runtime::ensure_initialized(); log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len()); - let client = Client::builder() - .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) - .user_agent("Mozilla/5.0 (Android; Mobile; Straw/0.1)") - .build() - .map_err(|e| StrawcoreError::Extractor { - msg: format!("http client build: {e}"), - })?; - Ok(fetch_channel_rss(&client, &channel_url).await.unwrap_or_default()) + let client = rss_client()?; + Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default()) } /// Bulk subscription feed fan-out — for callers that want one round-trip @@ -88,68 +122,109 @@ pub async fn subscription_feed( if channel_urls.is_empty() { return Ok(Vec::new()); } - let client = Client::builder() - .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) - .user_agent("Mozilla/5.0 (Android; Mobile; Straw/0.1)") - .build() - .map_err(|e| StrawcoreError::Extractor { - msg: format!("http client build: {e}"), - })?; + let client = rss_client()?; let results: Vec> = stream::iter(channel_urls.into_iter()) - .map(|url| { - let client = client.clone(); - async move { fetch_channel_rss(&client, &url).await.unwrap_or_default() } - }) + .map(|url| async move { fetch_channel_rss(client, &url).await.unwrap_or_default() }) .buffer_unordered(MAX_CONCURRENT) .collect() .await; - let mut flat: Vec = results.into_iter().flatten().collect(); - // Newest first by published timestamp baked into the upload_date_relative - // field at parse time — RSS already returns entries newest-first per - // channel so we mostly just need cross-channel interleave. - flat.sort_by(|a, b| b.upload_date_relative.cmp(&a.upload_date_relative)); - Ok(flat) + // Per-channel ordering is RSS-served-newest-first. Cross-channel + // interleave is the caller's responsibility — Kotlin's mergeFromCache + // sorts by parsed recency, which is the source of truth. Returning + // the flat list as-is. (vc=66 prior code sorted lexicographically + // on the relative-date STRING, which is wrong because "10 hours + // ago" < "2 hours ago" in cmp order — round-67 audit rust-HIGH-6.) + Ok(results.into_iter().flatten().collect()) } async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option> { let channel_id = extract_channel_id(channel_url)?; let url = format!("{RSS_BASE}{channel_id}"); - let body = client + let resp = client .get(&url) .send() .await .ok()? .error_for_status() - .ok()? - .text() - .await .ok()?; + // Streaming body read with a hard byte cap — `.text()` reads + // unbounded into a String. Round-67 audit rust-HIGH-5. + let body = read_capped_body(resp).await?; parse_rss(&body, channel_id) } -/// Extract the `UCxxx` channel ID from a channel URL. Handles the -/// common shapes: +/// Drain a reqwest Response into a String, bailing out (return None) if +/// the body exceeds RSS_MAX_BYTES. Round-67 audit rust-HIGH-5. +async fn read_capped_body(resp: reqwest::Response) -> Option { + use futures::StreamExt; + let mut total = 0usize; + let mut buf: Vec = Vec::with_capacity(32 * 1024); + let mut stream = resp.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.ok()?; + total = total.saturating_add(chunk.len()); + if total > RSS_MAX_BYTES { + log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting"); + return None; + } + buf.extend_from_slice(&chunk); + } + String::from_utf8(buf).ok() +} + +/// Extract the `UCxxx` channel ID from a channel URL. Accepts the +/// shapes the Android app actually has in Subscriptions plus the ones +/// users paste from share intents: /// * `https://www.youtube.com/channel/UCxxx...` -/// * `https://www.youtube.com/UCxxx...` (canonical clone) +/// * `https://youtube.com/channel/UCxxx...` +/// * `http(s)://m.youtube.com/channel/UCxxx...` +/// * trailing `/videos`, `?si=...`, etc — anything after the ID is dropped /// * raw `UCxxx...` (already an ID) /// +/// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish). +/// Round-67 audit rust-HIGH-1. +/// /// `@handle` URLs are NOT supported here — RSS requires the channel ID. -/// Callers that only have an @handle should resolve via channel_info() -/// once, cache the ID into Subscriptions, and pass the ID forever after. +/// Callers with @handles should resolve via channel_info() once and +/// cache the ID into Subscriptions. fn extract_channel_id(input: &str) -> Option { let trimmed = input.trim(); - if let Some(stripped) = trimmed.strip_prefix("https://www.youtube.com/channel/") { - return Some(stripped.split('/').next()?.to_string()); + let trimmed_lower = trimmed.to_lowercase(); + // Match the ":///channel/" prefix in a single sweep + // so we accept http/https + www./m. variants without four-way + // string-strip ladders. + const PREFIXES: &[&str] = &[ + "https://www.youtube.com/channel/", + "https://youtube.com/channel/", + "https://m.youtube.com/channel/", + "http://www.youtube.com/channel/", + "http://youtube.com/channel/", + "http://m.youtube.com/channel/", + ]; + for p in PREFIXES { + if let Some(idx) = trimmed_lower.find(p) { + let rest = &trimmed[idx + p.len()..]; + let id = rest.split(|c: char| c == '/' || c == '?' || c == '#').next()?; + return validate_channel_id(id); + } } - if let Some(stripped) = trimmed.strip_prefix("https://youtube.com/channel/") { - return Some(stripped.split('/').next()?.to_string()); + validate_channel_id(trimmed) +} + +/// A real YouTube channel ID is `UC` followed by exactly 22 chars from +/// `[A-Za-z0-9_-]`. Round-67 audit rust-HIGH-1. +fn validate_channel_id(id: &str) -> Option { + if id.len() != 24 || !id.starts_with("UC") { + return None; } - if trimmed.starts_with("UC") && trimmed.len() >= 22 && trimmed.len() <= 26 { - return Some(trimmed.to_string()); + if !id.bytes().skip(2).all(|b| { + matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-') + }) { + return None; } - None + Some(id.to_string()) } fn parse_rss(body: &str, channel_id: String) -> Option> { @@ -242,7 +317,11 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { let name = e.name(); let local = local_name(name.as_ref()); if local == "entry" { - if !video_id.is_empty() { + // Skip entries missing the load-bearing fields — + // an empty title renders as a blank card the user + // can't tap, and an empty published collapses the + // recency sort. Round-67 audit rust-HIGH-2. + if !video_id.is_empty() && !title.is_empty() && !published.is_empty() { items.push(SearchItem { url: format!("https://www.youtube.com/watch?v={video_id}"), title: title.clone(), @@ -266,6 +345,12 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // first in the fan-out. Caught 2026-05-26. upload_date_relative: iso_to_relative(&published), }); + if items.len() >= RSS_MAX_ENTRIES { + // Defense-in-depth against a feed that + // ships thousands of blocks. + // Round-67 audit rust-MED-6. + return Some(items); + } } in_entry = false; depth = 0; @@ -275,7 +360,15 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { text_target = None; } Ok(Event::Eof) => break, - Err(_) => return None, + // Partial-parse on error: return whatever we've already + // collected rather than throwing the whole batch away. + // A truncated body (EOF mid-stream on a flaky network) + // would otherwise silently disappear the channel. + // Round-67 audit rust-CRIT-3. + Err(e) => { + log::warn!("strawcore::rss parse error after {} items: {e}", items.len()); + return Some(items); + } _ => {} } buf.clear(); @@ -307,7 +400,16 @@ fn iso_to_relative(iso: &str) -> String { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); - format_relative(now_secs.saturating_sub(secs)) + // A device with a skewed clock can see RSS timestamps as future- + // dated. saturating_sub returns 0 → "0 seconds ago" → sorts to + // top, which is the LTT/WTYP-recurrence vector. Treat future + // dates as "just now" so the relative-string sort behaves and + // a single skewed item doesn't pin itself at the top of the + // feed. Round-67 audit rust-HIGH-7. + if secs > now_secs { + return "just now".to_string(); + } + format_relative(now_secs - secs) } fn parse_rfc3339_secs(s: &str) -> Option { @@ -327,6 +429,13 @@ fn parse_rfc3339_secs(s: &str) -> Option { let hh: u32 = time_parts.next()?.parse().ok()?; let mm: u32 = time_parts.next()?.parse().ok()?; let ss: u32 = time_parts.next()?.parse().ok()?; + // Year clamp BEFORE civil_to_days — out-of-range years overflow + // the era arithmetic in debug, wrap in release. A hostile feed + // serving year=2147483647 must not produce junk timestamps. + // Round-67 audit rust-CRIT-1. + if !(YEAR_MIN..=YEAR_MAX).contains(&y) { + return None; + } if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 { return None; } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt index c18448bfd..5851fda9c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -76,6 +76,17 @@ class EnrichmentStore(context: Context) { ) val before = _entries.value val next = _entries.updateAndGet { current -> + // Round-67 audit HIGH-4: short-circuit when the cached + // value is already the same view+duration — re-enriching + // within TTL otherwise allocates a new Map every call + // and the `before !== next` guard never triggers, so a + // refresh-after-refresh hammers the SP file. + val existing = current[videoId] + if (existing != null && + existing.viewCount == entry.viewCount && + existing.durationSeconds == entry.durationSeconds) { + return@updateAndGet current + } val withEntry = current + (videoId to entry) if (withEntry.size > MAX_ENRICHMENTS) { withEntry.entries @@ -98,7 +109,14 @@ class EnrichmentStore(context: Context) { private fun load(): Map = runCatching { val s = sp.getString(KEY, null) ?: return emptyMap() - json.decodeFromString>(s) + val loaded = json.decodeFromString>(s) + // Round-67 audit MED-6: prune TTL-expired entries on load + // so the store doesn't accumulate dead weight up to + // MAX_ENRICHMENTS over time. `Forever` TTL skips the prune. + val ttl = Settings.get().cacheTtl.value + if (ttl.isForever) return loaded + val cutoff = System.currentTimeMillis() - ttl.ms + loaded.filterValues { it.fetchedAt >= cutoff } }.getOrDefault(emptyMap()) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 42ddfc8db..9ef0c07bc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -17,10 +17,13 @@ package com.sulkta.straw.data import android.content.Context import android.content.SharedPreferences +import com.sulkta.straw.StrawApp +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -94,7 +97,27 @@ class ResumePositionsStore(context: Context) { ) val before = _positions.value val next = _positions.updateAndGet { current -> + // Round-67 audit HIGH-6: short-circuit value-equality — + // a 5s poll tick that finds the same (position, duration, + // wall-time) for an existing entry returns `current` + // unchanged so the outer `next !== before` guard + // actually short-circuits the SP write. + // + // lastWatchedAt updates every tick by definition, but + // ResumePosition equality on position+duration alone is + // ALL we care about for "did anything meaningful change." + // We re-stamp lastWatchedAt only when the player position + // actually advances. + val existing = current[videoId] + if (existing != null && + existing.positionMs == entry.positionMs && + existing.durationMs == entry.durationMs) { + return@updateAndGet current + } val withEntry = current + (videoId to entry) + // Skip sort+associate when we're under the cap (the + // common case at default 500). Sort is O(n log n); + // associate allocates another map. Round-67 audit HIGH-6. if (withEntry.size > maxResumes()) { // Drop oldest by lastWatchedAt — newcomers always land // because the entry we just added is by definition the @@ -108,7 +131,13 @@ class ResumePositionsStore(context: Context) { } } if (next !== before) { - sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + // JSON encode + SP write off Main — encoding 100k entries + // would be ~50-100 ms on a low-end device, and the 5s + // captureResumePosition poll runs on Main. Round-67 + // audit HIGH-6. + StrawApp.globalScope.launch(Dispatchers.IO) { + sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + } } } @@ -125,13 +154,20 @@ class ResumePositionsStore(context: Context) { if (videoId !in current) current else current - videoId } if (next !== before) { - sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + StrawApp.globalScope.launch(Dispatchers.IO) { + sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() + } } } fun clearAll() { + val before = _positions.value _positions.updateAndGet { emptyMap() } - sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap())).apply() + if (before.isNotEmpty()) { + StrawApp.globalScope.launch(Dispatchers.IO) { + sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap())).apply() + } + } } private fun load(): Map = runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 76a12d77e..665e57afe 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -109,8 +109,6 @@ class VideoDetailViewModel : ViewModel() { private val _ui = MutableStateFlow(VideoDetailUiState()) val ui: StateFlow = _ui.asStateFlow() - private var loadedUrl: String? = null - // Track the active load coroutine so a rapid tap to a different video // cancels the prior fetch; otherwise a slow-to-finish older load // overwrites the newer state and the player ends up streaming A while @@ -120,23 +118,31 @@ class VideoDetailViewModel : ViewModel() { fun load(streamUrl: String) { // viewModel() is activity-scoped, so the same VM is reused across // navigations. Skip the refetch if the requested URL already has - // a resolved state. - if (loadedUrl == streamUrl && _ui.value.detail != null) return + // a resolved state. Snapshot _ui once so the two reads agree. + val snap = _ui.value + if (snap.loadedUrl == streamUrl && snap.detail != null) return // Same YT-host gate as ChannelViewModel — covers the case // where a tap on a poisoned related-card lands here. // Round-5 audit MED-3. Round-6 audit HIGH-1: cancel any // in-flight load on rejection too — otherwise the // late-arriving prior-job's fence still PASSES (loadedUrl // wasn't moved) and clobbers the "Unsupported URL" error - // banner. + // banner. round-67 audit HIGH-7: also set loadedUrl on this + // path so the gate reads coherently for any caller that + // checks _ui.value.loadedUrl on the rejected path. if (!isAllowedYtUrl(streamUrl)) { inFlight?.cancel() inFlight = null - _ui.update { VideoDetailUiState(loading = false, error = "Unsupported URL") } + _ui.update { + VideoDetailUiState( + loading = false, + error = "Unsupported URL", + loadedUrl = streamUrl, + ) + } return } inFlight?.cancel() - loadedUrl = streamUrl _ui.update { VideoDetailUiState(loading = true, loadedUrl = streamUrl) } inFlight = viewModelScope.launch { try { @@ -261,8 +267,10 @@ class VideoDetailViewModel : ViewModel() { // loads: if a subsequent load(B) cancelled this one but // we resolved past the suspension point, drop our // result rather than clobber B's state. Round-4 audit - // HIGH-2. - if (loadedUrl != streamUrl) return@launch + // HIGH-2. Round-67 audit HIGH-7: single source of + // truth — read loadedUrl from _ui rather than a + // shadowing field. + if (_ui.value.loadedUrl != streamUrl) return@launch _ui.update { VideoDetailUiState( loading = false, @@ -288,7 +296,7 @@ class VideoDetailViewModel : ViewModel() { } } catch (t: Throwable) { if (t is CancellationException) throw t - if (loadedUrl != streamUrl) return@launch + if (_ui.value.loadedUrl != streamUrl) return@launch _ui.update { VideoDetailUiState( loading = false, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt index 9d0624845..ad4f8922f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshScheduler.kt @@ -29,8 +29,10 @@ object FeedRefreshScheduler { wm.cancelUniqueWork(WORK_NAME) return } + // WorkManager 15-minute periodic floor — see UpdateScheduler. + // Round-67 audit MED-4. val request = PeriodicWorkRequestBuilder( - s.bgFeedRefreshInterval.value.minutes, + s.bgFeedRefreshInterval.value.minutes.coerceAtLeast(15L), TimeUnit.MINUTES, ).setConstraints( Constraints.Builder() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt index 312732be1..849988922 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/FeedRefreshWorker.kt @@ -27,6 +27,8 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogI +import com.sulkta.straw.util.strawLogW +import java.io.IOException class FeedRefreshWorker( context: Context, @@ -41,9 +43,21 @@ class FeedRefreshWorker( // One bulk call via the Rust subscriptionFeed fan-out. Returns // a flat list; we group by uploaderUrl to rebuild the per- // channel cache shape FeedCacheStore expects. - val flat = runCatching { + // + // Round-67 audit MED-5: distinguish transient failures + // (network down, timeout) from parse failures. The former + // wants Result.retry() so WorkManager re-attempts within the + // current window with exponential backoff; without this, a + // 30-second offline blip eats a full 6-hour refresh cycle. + val flat = try { uniffi.strawcore.subscriptionFeed(subs.map { it.url }) - }.getOrNull() ?: return Result.success() + } catch (e: IOException) { + strawLogW("FeedRefresh") { "transient network failure, retrying: ${e.message}" } + return Result.retry() + } catch (e: Throwable) { + strawLogW("FeedRefresh") { "non-transient failure, giving up this cycle: ${e.message}" } + return Result.success() + } val now = System.currentTimeMillis() val grouped: Map = flat diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index eb393c851..391105a11 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -91,12 +91,17 @@ class SubscriptionFeedViewModel : ViewModel() { if (channels.isNotEmpty()) { pruneCacheToSubs(channels) val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L + // Compute the merge off-Main first (round-67 audit + // HIGH-1) — flatMap + regex + sort on hydration was + // running on Main and could add ~10-20 ms to cold + // start on a slow phone. + val hydrated = withContext(Dispatchers.Default) { mergeFromCache(channels) } // _ui.update so a concurrent refresh()'s state write // doesn't race with this copy. vc=37 round-3 audit // HIGH-4. Only advance lastFetchedAt — never regress. _ui.update { it.copy( - items = mergeFromCache(channels), + items = hydrated, lastFetchedAt = maxOf(it.lastFetchedAt, savedTs), ) } @@ -134,6 +139,16 @@ class SubscriptionFeedViewModel : ViewModel() { /** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */ private var inFlight: Job? = null + /** + * The background enrichment job runs on StrawApp.globalScope so it + * outlives the VM's viewModelScope — but a refresh-cancel must + * still kill the *previous* enrichment so we don't pile up + * overlapping fan-outs (8-wide × N overlapping refreshes blows the + * concurrency budget). Tracked here, cancelled in the same places + * `inFlight` is. Round-67 audit HIGH-2/3/8. + */ + private var enrichJob: Job? = null + fun refreshIfStale() { // Skip if a refresh is already in flight. vc=36 audit CRIT-R1: // SubsPane's LaunchedEffect(subs) re-fires every time @@ -160,8 +175,11 @@ class SubscriptionFeedViewModel : ViewModel() { // channelCache when the user unsubscribes from the last // channel; we'd clear() then immediately repopulate with // phantom entries when the prior fetchChannelInto resolved. - // vc=37 round-3 audit HIGH-3. + // vc=37 round-3 audit HIGH-3. Also kill any in-flight + // enrichment fan-out so we don't end up with N overlapping + // enrich jobs piling up under spam-refresh — round-67 HIGH-8. inFlight?.cancel() + enrichJob?.cancel() val channels = Subscriptions.get().subs.value if (channels.isEmpty()) { _ui.update { it.copy(loading = false, items = emptyList(), error = null) } @@ -193,7 +211,11 @@ class SubscriptionFeedViewModel : ViewModel() { .awaitAll() } pruneCacheToSubs(channels) - val freshItems = mergeFromCache(channels) + // Move flatMap + per-item regex + sort off Main — + // viewModelScope.launch runs on Main by default and + // mergeFromCache is non-trivial on a 500-item merge. + // Round-67 audit HIGH-1. + val freshItems = withContext(Dispatchers.Default) { mergeFromCache(channels) } _ui.update { SubscriptionFeedUiState( loading = false, @@ -205,7 +227,11 @@ class SubscriptionFeedViewModel : ViewModel() { // viewCount=0 + durationSeconds=0; kick a bounded // background job that calls enrichFeedItem for the // top items and pumps a fresh _ui emit when done. - enrichVisibleItems(freshItems) + // Pass the channels snapshot so the enrich job's + // terminal mergeFromCache uses what was current at + // job start, not whatever the user's subs are by + // the time enrichment finishes ~2s later. + enrichVisibleItems(freshItems, channels) // Persist what we just freshened. Off the main thread — // JSON encode on 30 subs * 30 items is small but not // free, and SharedPreferences.apply is async anyway. @@ -317,15 +343,19 @@ class SubscriptionFeedViewModel : ViewModel() { * complete in ~2s. Skipped per-item when FeedEnrichment already * has a fresh hit (TTL controlled by Settings.cacheTtl). * - * Runs OFF viewModelScope so a refresh-cancel doesn't kill an - * enrichment that's almost done — the background fill is for - * NEXT-open paint, no rush. Uses StrawApp.globalScope. + * Runs on viewModelScope (round-67 audit HIGH-2): outliving the VM + * would mean a destroyed _ui can still receive a stale emit (and + * mergeFromCache reads a now-cleared channelCache). The next + * VM instance does its own enrichment on next refresh; nothing + * is lost by not finishing the prior one. Tracked in enrichJob so + * refresh + clearInMemoryCache can cancel it. */ - private fun enrichVisibleItems(items: List) { + private fun enrichVisibleItems(items: List, channelsSnapshot: List) { val take = items.take(ENRICH_HEAD_COUNT) .filter { it.viewCount <= 0L && it.durationSeconds <= 0L } if (take.isEmpty()) return - com.sulkta.straw.StrawApp.globalScope.launch { + enrichJob?.cancel() + enrichJob = viewModelScope.launch { val gate = Semaphore(ENRICH_PARALLELISM) coroutineScope { take.map { item -> @@ -348,11 +378,14 @@ class SubscriptionFeedViewModel : ViewModel() { } }.awaitAll() } - // Pump a fresh emit so the UI picks up the overlay. - withContext(Dispatchers.Main) { - val channels = Subscriptions.get().subs.value - _ui.update { it.copy(items = mergeFromCache(channels)) } + // Compute the merge off-Main — flatMap + per-item regex + // + sort over up to 500 items is too much for the UI + // thread. Then hop to Main only for the StateFlow emit. + // Round-67 audit HIGH-1. + val merged = withContext(Dispatchers.Default) { + mergeFromCache(channelsSnapshot) } + _ui.update { it.copy(items = merged) } } } @@ -385,8 +418,13 @@ class SubscriptionFeedViewModel : ViewModel() { fun clearInMemoryCache() { // Cancel any in-flight refresh — without this, fetchChannelInto // coroutines mid-execution would re-populate the cache after - // the clear. Round-3 audit function MED-3. + // the clear. Round-3 audit function MED-3. Also cancel any + // enrichment fan-out (lives on globalScope, NOT viewModelScope) + // — otherwise a still-running enrichment would write to + // FeedEnrichment + then push a merged emit reading the empty + // channelCache. Round-67 audit HIGH-3. inFlight?.cancel() + enrichJob?.cancel() channelCache.clear() // Use _ui.update for atomicity vs concurrent refresh writes // (round-3 audit HIGH-4). diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt index 26daa53f0..4e31e6b12 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt @@ -26,7 +26,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,10 +59,20 @@ fun BoxScope.ThumbnailProgressOverlay(videoId: String?) { // scroll jank (vc=67). The Lifecycle pause optimization doesn't // matter for a foreground feed that's only collected while the // composable is on screen anyway. - val positions by Resume.get().positions.collectAsState() - val entry = positions[videoId] ?: return - if (entry.durationMs <= 0L) return - val fraction = (entry.positionMs.toFloat() / entry.durationMs.toFloat()) + // + // Round-67 audit MED-2: derivedStateOf isolates each row's + // dependency to ONLY its own videoId's entry. Without this, the + // 5s captureResumePosition tick re-emits the entire positions + // map → every visible thumbnail recomposes. With it, only rows + // whose specific entry changed recompose. + val positionsFlow = Resume.get().positions + val positions by positionsFlow.collectAsState() + val entry by remember(videoId) { + derivedStateOf { positions[videoId] } + } + val resolved = entry ?: return + if (resolved.durationMs <= 0L) return + val fraction = (resolved.positionMs.toFloat() / resolved.durationMs.toFloat()) .coerceIn(0f, 1f) Box( modifier = Modifier diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt index 4f0eacc6c..69428cec1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt @@ -16,17 +16,37 @@ package com.sulkta.straw.feature.update import com.sulkta.straw.BuildConfig import com.sulkta.straw.util.runCatchingCancellable +import com.sulkta.straw.util.strawLogW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import okhttp3.CertificatePinner import okhttp3.OkHttpClient import okhttp3.Request import java.util.concurrent.TimeUnit +private const val INDEX_HOST = "fdroid.sulkta.com" private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json" private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo" +/** + * Only accept file names that look like a plain APK basename. The index + * controls a string we substitute into an `ACTION_VIEW` intent; without + * sanitization a hostile or compromised index could ship `..//host/x.apk` + * or worse. Round-67 audit HIGH-5. + */ +private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""") + +/** + * Sanity-cap on parsed versionCode. Straw vc is currently low double + * digits; ten million is a horizon we won't hit organically but blocks + * a hostile index from latching us to Long.MAX_VALUE and burying every + * legitimate update behind a "you're already up to date" check. + * Round-67 audit HIGH-5. + */ +private const val MAX_PLAUSIBLE_VC = 10_000_000L + data class UpdateInfo( val versionCode: Long, val versionName: String, @@ -34,9 +54,31 @@ data class UpdateInfo( ) object AppUpdateClient { + /** + * Pin two Subject-Public-Key-Info SHA-256 hashes against + * fdroid.sulkta.com so an off-tree CA misissue can't ship the + * user an attacker-signed index. Round-67 audit HIGH-5. + * + * - sha256/8ofd... — current leaf SPKI. Rotates every ~90 days + * with each Let's Encrypt renewal; an app update before the + * next rotation refreshes this pin. + * - sha256/y7xV... — Let's Encrypt E7 intermediate SPKI. Stable + * for years; serves as the rotation-safety pin while we push + * a new leaf hash. + * + * When the leaf pin no longer matches (post-rotation), OkHttp + * still accepts the chain because the E7 intermediate pin + * matches. The next app release rolls the leaf forward. + */ + private val pinner: CertificatePinner = CertificatePinner.Builder() + .add(INDEX_HOST, "sha256/8ofdiPS6TAiUx9zb2O7Qa9IKZQ3D2i+18teKCrz/MqA=") + .add(INDEX_HOST, "sha256/y7xVm0TVJNahMr2sZydE2jQH8SquXV9yLF9seROHHHU=") + .build() + private val http: OkHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) + .certificatePinner(pinner) .build() private val json = Json { ignoreUnknownKeys = true } @@ -58,10 +100,29 @@ object AppUpdateClient { val best = pkg.versions.values .maxByOrNull { it.manifest.versionCode } ?: return@runCatchingCancellable null + // Reject implausible versionCodes outright — see + // MAX_PLAUSIBLE_VC. Round-67 audit HIGH-5. + if (best.manifest.versionCode <= 0 || + best.manifest.versionCode > MAX_PLAUSIBLE_VC) { + strawLogW("StrawUpdate") { + "rejecting implausible versionCode=${best.manifest.versionCode}" + } + return@runCatchingCancellable null + } + // Strict APK-basename match before we hand this off to + // ACTION_VIEW. Anything else gets logged + dropped. + // Round-67 audit HIGH-5. + val fileName = best.file.name + if (!APK_NAME_RE.matches(fileName)) { + strawLogW("StrawUpdate") { + "rejecting unsafe file.name=${fileName.take(80)}" + } + return@runCatchingCancellable null + } UpdateInfo( versionCode = best.manifest.versionCode, versionName = best.manifest.versionName.orEmpty(), - apkUrl = "$REPO_BASE${best.file.name}", + apkUrl = "$REPO_BASE$fileName", ) }.getOrNull() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt index b24ffe6e5..d91cf3461 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt @@ -34,8 +34,12 @@ object UpdateScheduler { wm.cancelUniqueWork(WORK_NAME) return } + // WorkManager floors periodic intervals at 15 minutes. + // coerceAtLeast(15) future-proofs against a smaller enum case + // landing without anyone noticing the silent clamp. Round-67 + // audit MED-4. val request = PeriodicWorkRequestBuilder( - interval.minutes, + interval.minutes.coerceAtLeast(15L), TimeUnit.MINUTES, ).setConstraints( Constraints.Builder() From 5f2ba264b068bbf271641d8910f92b077f525c9d Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 20:56:24 -0700 Subject: [PATCH 67/87] vc=68 fixup: enable reqwest 'stream' feature for bytes_stream --- rust/strawcore/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 630ef94ef..047f10f69 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -41,7 +41,7 @@ android_logger = { version = "0.14", default-features = false } # strawcore-core's already-pulled reqwest; quick-xml is small (~200KB); # futures for buffer_unordered. rustls-tls avoids the NDK openssl headers # headache. -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "stream"] } quick-xml = "0.36" futures = "0.3" From 23fb6f52b01800a00092ba8f2c5d9ac15609a99c Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 21:31:07 -0700 Subject: [PATCH 68/87] vc=69: audit-fix sprint round 2 (regressions on round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit caught four real regressions on round-1 fixes plus a handful of MEDs. This sprint fixes them. H1 — FeedRefreshWorker exception class Round 1 wrapped subscriptionFeed in try/catch IOException, but UniFFI generates StrawcoreException (kotlin.Exception, not IOException). The retry path was dead code. Catch StrawcoreException.Network instead — the variant our error.rs maps NetworkError::Transport into. H2 — enrichJob terminal emit cancellation race withContext(Dispatchers.Default) { mergeFromCache(...) } has no suspension points so a cancel arriving mid-merge isn't observed until the next suspending call. Without a guard, the non-suspending _ui.update lands AFTER clearInMemoryCache() and resurrects the cleared items. Add coroutineContext.ensureActive() after each withContext hop, before the emit. Applied on both the refresh terminal emit and the enrich terminal emit. H6 — enrichVisibleItems shows stale subscriptions The channelsSnapshot captured at refresh-end is ~2s stale by the time the enrich terminal emit runs. If the user unsubscribed from X in that window, X's items still appear on the feed for one frame. Re-read Subscriptions at the terminal step and intersect with the snapshot. R-H3 — extract_channel_id substring match Round 1 used trimmed_lower.find(prefix) which matches ANY position. evil.com/?redir=https://www.youtube.com/channel/UCxxx silently rewrote to the embedded channel ID. strip_prefix() anchors at byte 0. ASCII-only prefix means byte indices align in trimmed_lower vs trimmed. R-H2 — String::from_utf8 silent-drop YouTube ships mojibake titles in the wild. Strict from_utf8 returned None on any bad byte, dropping the entire channel from the feed with only a quiet None. Switch to from_utf8_lossy — quick-xml tolerates U+FFFD replacement chars and the per-entry skip-on-empty handles broken entries. R-H1 — read_capped_body per-chunk size sanity HTTP allows arbitrarily large single chunks. Reject any chunk exceeding the whole body cap before adding it to the buffer, so a hostile server can't get us to allocate a hyper Bytes larger than the cap. M3 — Avatar URL validation ch.avatar is extractor-emitted; a poisoned channel page could ship data:image/svg+xml,...

We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.

-

Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.

+# Straw -

-

NewPipe

-

A libre lightweight streaming front-end for Android.

+A Sulkta fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe). Android YouTube +client, Compose UI, Media3 player, with [SponsorBlock](https://sponsor.ajay.app/) +and [Return YouTube Dislike](https://returnyoutubedislike.com/) baked in. -

Get it on F-Droid

+The extractor is `strawcore`, a Rust port of NewPipeExtractor exposed to Kotlin +via UniFFI. No InnerTube/JS deobf code path lives on the JVM anymore. -

- - - - - - -

+## Install -

- - -

+F-Droid repo: -
-

ScreenshotsSupported ServicesDescriptionFeaturesInstallation and updatesContributionDonateLicense

-

WebsiteBlogFAQPress

-
+Add the repo in your F-Droid client of choice, then install Straw. -*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)* +The app also self-updates from the same repo when an APK lands there with a +higher `versionCode`. -> [!warning] -> THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE. -> -> PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. +## What's in -## Screenshots +- Search, video detail, channel pages, playlists +- Inline player + fullscreen + minibar + background audio + PiP +- Media3 ExoPlayer (DASH / HLS / progressive / merged DASH chunks) +- SponsorBlock auto-skip (categories user-toggleable) +- Return YouTube Dislike on video detail +- RSS-based subscription feed (fast — ~1s for 50 subs) +- Hide-shorts / hide-paid / hide-age-restricted feed filters +- Resume positions + watch history + search history +- Local playlists, downloads (video + audio) +- NewPipe-format settings import (subs + playlists + history) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png) -

-[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png) -[](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png) +## What's out (on purpose) -### Supported Services +- Trending / algorithmic feeds. Subscriptions only. +- iOS / desktop targets. Android-only for now. +- Google Play Services anything. -NewPipe currently supports these services: +## Layout - -* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube)) -* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube)) -* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp)) -* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud)) -* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club)) - -As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile! - -Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube. - -If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor). - -## Description - -NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe. - -Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed. - -### Features - -* Watch videos at resolutions up to 4K -* Listen to audio in the background, only loading the audio stream to save data -* Popup mode (floating player, aka Picture-in-Picture) -* Watch live streams -* Show/hide subtitles/closed captions -* Search videos and audios (on YouTube, you can specify the content language as well) -* Enqueue videos (and optionally save them as local playlists) -* Show/hide general information about videos (such as description and tags) -* Show/hide next/related videos -* Show/hide comments -* Search videos, audios, channels, playlists and albums -* Browse videos and audios within a channel -* Subscribe to channels (yes, without logging into any account!) -* Get notifications about new videos from channels you're subscribed to -* Create and edit channel groups (for easier browsing and management) -* Browse video feeds generated from your channel groups -* View and search your watch history -* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing) -* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service) -* Download videos/audios/subtitles (closed captions) -* Open in Kodi -* Watch/Block age-restricted material - - - - -## Installation and updates -You can install NewPipe using one of the following methods: - 1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ - 2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it. - 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users. - 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. - 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up. - -We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK. - -In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure: -1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists -2. Uninstall NewPipe -3. Download the APK from the new source and install it -4. Import the data from step 1 via Settings > Backup and Restore > Import Database - -> [!Note] -> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing. - -### APK Info - -This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2. ``` -CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB +strawApp/ Sulkta-authored app — Compose UI, Media3 wiring, SB + RYD clients +rust/ strawcore — UniFFI wrapper around the Rust extractor +shared/ KMP scaffold inherited from upstream NewPipe (unused for now) +app/ Upstream NewPipe :app module — kept for reference ``` -## Contribution -Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). +## Build - -Translation status - +``` +./gradlew :strawApp:assembleDebug +``` -## Donate -If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate). +Requires the Rust toolchain plus the four Android targets: - - - - - - -
LiberapayVisit NewPipe at liberapay.comDonate via Liberapay
+``` +rustup target add aarch64-linux-android armv7-linux-androideabi \ + x86_64-linux-android i686-linux-android +cargo install cargo-ndk uniffi-bindgen +``` -## Privacy Policy - -The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/). +…and `ANDROID_NDK_HOME` pointing at NDK r27c (or newer). The Gradle build runs +`cargo ndk` + `uniffi-bindgen` automatically. ## License -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) -NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +GPL-3.0-or-later, inherited from upstream NewPipe. + +## Upstream + +This repo tracks . Upstream changes +get pulled periodically via the `upstream` remote. + +## Disclaimer + +Not affiliated with YouTube, Google, NewPipe e.V., the SponsorBlock project, +or Return YouTube Dislike. Trademarks belong to their owners. Straw uses +public web endpoints; nothing here authenticates to any account. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7ee7f3ec7..880e1f4b7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,7 +14,7 @@ members = ["strawcore"] edition = "2021" license = "GPL-3.0-or-later" authors = ["Sulkta-Coop"] -repository = "http://192.168.0.5:3001/Sulkta-Coop/straw" +repository = "https://git.sulkta.com/Sulkta-Coop/straw" [profile.release] # Strip debug info, run thin LTO. APK size matters more than build time here. @@ -29,6 +29,6 @@ opt-level = "z" url = "2" [profile.dev] -# Keep debug builds fast — we're rebuilding constantly during U-1..U-5. +# Keep debug builds fast — we rebuild often during NDK cross-compile. opt-level = 0 debug = 1 diff --git a/rust/README.md b/rust/README.md index 2aa515b4c..720f81fa9 100644 --- a/rust/README.md +++ b/rust/README.md @@ -20,12 +20,12 @@ moves to Rust. ## Build chain ``` -crafting-table +Build container (Sulkta uses one; any toolchain matching this layout works) ├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi, │ x86_64-linux-android, i686-linux-android) ├── cargo-ndk (cross-compile helper) ├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms) -└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...) +└── android-ndk (ANDROID_NDK_HOME, r27c LTS) Gradle (strawApp/build.gradle.kts) ├── cargoBuild Exec task → cargo ndk -t ... -o jniLibs/ build --release diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 047f10f69..6ccd53fd5 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -30,14 +30,14 @@ strawcore-core = { path = "../../../strawcore" } # Android target has no pre-generated bindings — flip on the `bindgen` # feature so cargo regenerates at build time. Direct dep so the feature # flag propagates (cargo's unified feature resolver lifts this to the -# transitive use). Crafting-table has libclang preinstalled. +# transitive use). Build host needs libclang installed. rquickjs-sys = { version = "0.11", default-features = false, features = ["bindgen"] } # Error glue. thiserror = "1" # Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`. log = "0.4" android_logger = { version = "0.14", default-features = false } -# vc=56 — subscription RSS feed fan-out. reqwest dedupes against +# subscription RSS feed fan-out. reqwest dedupes against # strawcore-core's already-pulled reqwest; quick-xml is small (~200KB); # futures for buffer_unordered. rustls-tls avoids the NDK openssl headers # headache. diff --git a/rust/strawcore/src/error.rs b/rust/strawcore/src/error.rs index 7b840fbe3..2703813b5 100644 --- a/rust/strawcore/src/error.rs +++ b/rust/strawcore/src/error.rs @@ -69,7 +69,7 @@ impl From for StrawcoreError { // catches googlevideo.com hosts. The challenge URL // itself still solves without `continue=`, so the // user can tap to unblock without leaking the - // signature/expire/pot token. Round-4 audit LOW-1. + // signature/expire/pot token. StrawcoreError::RequiresLogin { detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)), } diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 54a372df5..c50b2942c 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -1,4 +1,4 @@ -// vc=56 — fast subscription feed via YouTube's per-channel RSS endpoint. +// fast subscription feed via YouTube's per-channel RSS endpoint. // // YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx` // — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the @@ -28,18 +28,15 @@ const PER_CHANNEL_TIMEOUT_S: u64 = 8; /// Cap on the body bytes we'll read for a single RSS fetch. Real YT /// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while /// blocking a hostile or compromised host from streaming GB-scale -/// bodies into JVM memory inside the 8s timeout. Round-67 audit -/// rust-HIGH-5. +/// bodies into JVM memory inside the 8s timeout. const RSS_MAX_BYTES: usize = 2 * 1024 * 1024; /// Cap on parsed entries per channel — RSS normally returns 15. /// 50 leaves headroom for one-off legitimate variance; anything /// past that is a sign the feed isn't what we expect. -/// Round-67 audit rust-MED-6. const RSS_MAX_ENTRIES: usize = 50; /// Year range we trust civil-to-days math for. Strawcore RSS only /// emits real-world recent uploads; clamping here turns adversarial /// year fields into a parse failure rather than i64 overflow. -/// Round-67 audit rust-CRIT-1. const YEAR_MIN: i32 = 1970; const YEAR_MAX: i32 = 2200; @@ -48,7 +45,7 @@ const YEAR_MAX: i32 = 2200; /// items after the RSS-fed paint to fill in the gaps that /// channel_feed_rss leaves empty. /// -/// vc=66 — built specifically so the subs feed can show 'N views · +/// built specifically so the subs feed can show 'N views · /// X duration' the way YT does, without paying the full channel_info /// page-scrape cost on initial paint. The underlying stream_info IS /// heavier than we'd like (~500ms each, runs JS deobf for play URLs @@ -75,7 +72,7 @@ pub async fn enrich_feed_item( /// Shared reqwest Client — DNS resolver + TLS keepalive + connection /// pool live here so a 50-channel fan-out reuses one pool instead of -/// paying 50 handshakes. Round-67 audit rust-HIGH-4. +/// paying 50 handshakes. static RSS_CLIENT: OnceLock = OnceLock::new(); fn rss_client() -> Result<&'static Client, StrawcoreError> { @@ -86,7 +83,7 @@ fn rss_client() -> Result<&'static Client, StrawcoreError> { .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) .user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")")) // Cap redirect chains so a misconfigured/hostile feed can't - // spin a server out of our 8s budget. Round-67 audit rust-LOW-8. + // spin a server out of our 8s budget. .redirect(reqwest::redirect::Policy::limited(3)) .build() .map_err(|e| StrawcoreError::Extractor { @@ -133,9 +130,9 @@ pub async fn subscription_feed( // Per-channel ordering is RSS-served-newest-first. Cross-channel // interleave is the caller's responsibility — Kotlin's mergeFromCache // sorts by parsed recency, which is the source of truth. Returning - // the flat list as-is. (vc=66 prior code sorted lexicographically + // the flat list as-is. (an earlier version sorted lexicographically // on the relative-date STRING, which is wrong because "10 hours - // ago" < "2 hours ago" in cmp order — round-67 audit rust-HIGH-6.) + // ago" < "2 hours ago" in cmp order) Ok(results.into_iter().flatten().collect()) } @@ -150,13 +147,13 @@ async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option Option { use futures::StreamExt; let mut total = 0usize; @@ -168,8 +165,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { // large (HTTP allows multi-GiB chunks). Reject any one chunk // bigger than the whole body cap before we even add it to the // running total — protects against hyper having already - // allocated the chunk on our behalf. Round-68 audit - // rust-HIGH-1. + // allocated the chunk on our behalf. if chunk.len() > RSS_MAX_BYTES { log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len()); return None; @@ -181,7 +177,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { } buf.extend_from_slice(&chunk); } - // Lossy decode — round-68 audit rust-HIGH-2. A strict from_utf8 + // Lossy decode — A strict from_utf8 // returns None on any invalid byte, so a single mojibake title // would silently drop the entire channel from the feed. quick-xml // tolerates U+FFFD replacement chars and the per-entry skip-on- @@ -199,7 +195,6 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { /// * raw `UCxxx...` (already an ID) /// /// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish). -/// Round-67 audit rust-HIGH-1. /// /// `@handle` URLs are NOT supported here — RSS requires the channel ID. /// Callers with @handles should resolve via channel_info() once and @@ -210,7 +205,7 @@ fn extract_channel_id(input: &str) -> Option { // Match the ":///channel/" prefix in a single sweep // so we accept http/https + www./m. variants without four-way // string-strip ladders. ANCHORED at the start of the string — - // round-68 audit rust-HIGH-3: prior `find()` accepted any input + // prior `find()` accepted any input // containing the prefix as a substring, so a pasted // `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would // silently rewrite to the wrong channel. @@ -237,7 +232,7 @@ fn extract_channel_id(input: &str) -> Option { } /// A real YouTube channel ID is `UC` followed by exactly 22 chars from -/// `[A-Za-z0-9_-]`. Round-67 audit rust-HIGH-1. +/// `[A-Za-z0-9_-]`. fn validate_channel_id(id: &str) -> Option { if id.len() != 24 || !id.starts_with("UC") { return None; @@ -343,7 +338,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // Skip entries missing the load-bearing fields — // an empty title renders as a blank card the user // can't tap, and an empty published collapses the - // recency sort. Round-67 audit rust-HIGH-2. + // recency sort. if !video_id.is_empty() && !title.is_empty() && !published.is_empty() { items.push(SearchItem { url: format!("https://www.youtube.com/watch?v={video_id}"), @@ -360,7 +355,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // RSS gives RFC3339 timestamps. Convert to // the human-relative format Kotlin's // recencyScore parser expects ("N units - // ago"). vc=56 was passing the raw ISO + // ago"). An earlier build was passing the raw ISO // through, which broke the sort comparator // — every item tied at MIN_VALUE so the // feed order was effectively random; LTT + @@ -371,7 +366,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { if items.len() >= RSS_MAX_ENTRIES { // Defense-in-depth against a feed that // ships thousands of blocks. - // Round-67 audit rust-MED-6. return Some(items); } } @@ -387,7 +381,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // collected rather than throwing the whole batch away. // A truncated body (EOF mid-stream on a flaky network) // would otherwise silently disappear the channel. - // Round-67 audit rust-CRIT-3. Err(e) => { log::warn!("strawcore::rss parse error after {} items: {e}", items.len()); return Some(items); @@ -428,7 +421,7 @@ fn iso_to_relative(iso: &str) -> String { // top, which is the LTT/WTYP-recurrence vector. Treat future // dates as "just now" so the relative-string sort behaves and // a single skewed item doesn't pin itself at the top of the - // feed. Round-67 audit rust-HIGH-7. + // feed. if secs > now_secs { return "just now".to_string(); } @@ -455,7 +448,6 @@ fn parse_rfc3339_secs(s: &str) -> Option { // Year clamp BEFORE civil_to_days — out-of-range years overflow // the era arithmetic in debug, wrap in release. A hostile feed // serving year=2147483647 must not produce junk timestamps. - // Round-67 audit rust-CRIT-1. if !(YEAR_MIN..=YEAR_MAX).contains(&y) { return None; } diff --git a/rust/strawcore/src/runtime.rs b/rust/strawcore/src/runtime.rs index 7e1ef14c8..336d6b4bb 100644 --- a/rust/strawcore/src/runtime.rs +++ b/rust/strawcore/src/runtime.rs @@ -3,7 +3,7 @@ // strawcore-core Downloader + Localization singleton so the extractor // has an HTTP client to use. // -// Round-4 audit HIGH-1: the prior shape used `Once::call_once` and +// the prior shape used `Once::call_once` and // silently swallowed errors. If the FIRST call ran while the network // stack wasn't ready (cold boot in airplane mode, SELinux denial on // first TLS init, transient resolver failure), the Once slot was @@ -60,7 +60,7 @@ pub fn ensure_initialized() { // DownloaderMissing once from the extractor and recover on // the next user action; the alternative (blocking N tokio // workers for the full duration of a slow init) freezes the - // UI. Round-6 audit HIGH-2 was the regression on round-5's + // UI. was the regression on round-5's // mutex-first ordering. let _guard = match INIT_LOCK.try_lock() { Ok(g) => g, diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index 056af6977..f99c29da9 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -58,9 +58,9 @@ pub async fn search(query: String) -> Result, StrawcoreError> { // names, sometimes embarrassing) and android_logger emits at // info-level in release builds, which means they'd ride the // Settings → Export Logs path straight into a user's chat. Log - // shape, not content. vc=36 audit CVE HIGH-2. + // shape, not content. log::info!("strawcore::search query_len={}", query.len()); - // Round-5 audit MED-1: ensure_initialized was only wired into + // ensure_initialized was only wired into // init_logging() so the 5s-backoff retry path never fired from // the hot entry points. Now every extractor entry re-asserts // — cheap when INITIALIZED is true (single Acquire load). diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index e6458448e..13fbac20b 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -154,7 +154,7 @@ dependencies { // - cargo + rustup with the four Android targets installed // - cargo-ndk on PATH // - ANDROID_NDK_HOME pointing at an NDK with the right toolchains -// All of that lives in the crafting-table container. +// All of that lives in the Sulkta build container. // ============================================================================= val rustRoot = file("../rust").absolutePath @@ -166,9 +166,10 @@ val cargoBin: String = "$cargoHome/bin/cargo" val ndkHome: String = System.getenv("ANDROID_NDK_HOME") ?: System.getenv("ANDROID_NDK_ROOT") ?: "/caches/android-sdk/ndk/27.2.12479018" -// Honor CARGO_TARGET_DIR if set (we redirect it to /caches on crafting-table -// because the container's writable rootfs hits 100% before the cross-compile -// for 4 ABIs finishes). Falls back to the default `/target`. +// Honor CARGO_TARGET_DIR if set (our build container redirects it to a +// cache mount because the container's writable rootfs hits 100% before +// the cross-compile for 4 ABIs finishes). Falls back to the default +// `/target`. val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR") ?: "$rustRoot/target" diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index e751d37f5..56adc1f94 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -38,11 +38,11 @@
+ with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home). + Was previously inlined in StrawActivity.kt under YT_HOSTS; + the two lists drifted (music.youtube.com etc. accepted by + code but never offered by the launcher disambig), so the + canonical list lives in one place now. --> @@ -63,11 +63,11 @@
- + @@ -74,7 +74,7 @@ class StrawApp : Application() { Playlists.init(this) Resume.init(this) FeedEnrichment.init(this) - // vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache + // FeedCache (~225 KB) + SearchCache // (~150 KB) JSON-decode at construction. Stash the // applicationContext eagerly (cheap) so `get()` is callable // anywhere; the actual store construction (and the disk @@ -83,7 +83,7 @@ class StrawApp : Application() { // main thread. FeedCache.init(this) SearchCache.init(this) - // vc=36 audit CVE HIGH-5: sweepStale's deleteRecursively() + // sweepStale's deleteRecursively // can walk ~256 MB if a previous import was LMK-killed // mid-extraction. Strictly off the main thread. appScope.launch { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 94ac9fe69..d62c68852 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -295,7 +295,7 @@ private fun SubsPane( LaunchedEffect(subs) { feedVm.refreshIfStale() } // Filter + pagination state. hideWatched is sticky for the session - // (no SharedPreferences yet — easy to add if Cobb wants persistence). + // (no SharedPreferences yet — easy to add if persistence is wanted). // visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time // the scroll passes ~5 items from the bottom of what's currently // visible. @@ -324,7 +324,7 @@ private fun SubsPane( } } // remember the page-slice so we don't allocate a new ArrayList on - // every recomposition (scroll hitch vc=67). + // every recomposition (scroll hitch). val displayed = remember(filteredItems, visibleCount) { filteredItems.take(visibleCount) } @@ -373,7 +373,7 @@ private fun SubsPane( Spacer(modifier = Modifier.height(16.dp)) // Show a slim error banner above cached items even if we have data — - // audit HIGH-7: previously a 401/429 looked identical to a successful + // previously a 401/429 looked identical to a successful // refresh because the error chip was hidden whenever items != empty. if (feed.error != null && feed.items.isNotEmpty()) { Text( @@ -425,7 +425,7 @@ private fun SubsPane( // (displayed.size, hasMore) was mutated BY this effect, // which cancelled the snapshotFlow collector mid-stream // and produced the "scrolled to bottom, nothing loads" - // bug from the vc=34 audit. + // bug from the audit. // // hasMore and filteredItems are read inside the // snapshotFlow producer (not closed over from outside) @@ -598,7 +598,7 @@ private fun SubChip( // width breaks the prior 2-line wrap mid-word ("NoCopyrightS // / ounds", "DEFCONConfe / rence") — uglier than a clean // "NoCopyrigh…". Centered text alignment so the ellipsis - // sits over the chip's icon column. vc=64. + // sits over the chip's icon column. Text( text = ch.name, style = MaterialTheme.typography.labelSmall, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt index 5851fda9c..597688f1e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -76,7 +76,7 @@ class EnrichmentStore(context: Context) { ) val before = _entries.value val next = _entries.updateAndGet { current -> - // Round-67 audit HIGH-4: short-circuit when the cached + // short-circuit when the cached // value is already the same view+duration — re-enriching // within TTL otherwise allocates a new Map every call // and the `before !== next` guard never triggers, so a @@ -110,7 +110,7 @@ class EnrichmentStore(context: Context) { private fun load(): Map = runCatching { val s = sp.getString(KEY, null) ?: return emptyMap() val loaded = json.decodeFromString>(s) - // Round-67 audit MED-6: prune TTL-expired entries on load + // prune TTL-expired entries on load // so the store doesn't accumulate dead weight up to // MAX_ENRICHMENTS over time. `Forever` TTL skips the prune. val ttl = Settings.get().cacheTtl.value diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index af886ccf5..eab61b975 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -40,7 +40,7 @@ class FeedCacheStore(context: Context) { /** * Snapshot of the disk cache, filtered by the user-configured TTL. - * Returns empty map if nothing saved or everything expired. vc=59 — + * Returns empty map if nothing saved or everything expired. * Settings.cacheTtl.isForever short-circuits the filter; finite TTLs * drop entries whose fetchedAt is older than (now - ttl). */ @@ -73,7 +73,7 @@ object FeedCache { * (and the ~225 KB JSON decode that happens at construction) is * deferred until the first `get()` call. Lets Application.onCreate * return quickly while every caller still gets a valid Store — - * vc=36 audit HIGH-R3. Callers should access from a coroutine + * Callers should access from a coroutine * (IO dispatcher) where the lazy construction cost is acceptable. */ fun init(context: Context) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 9e70c7101..1eb1bd2f8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -33,7 +33,7 @@ private const val KEY_WATCHES = "watches_v1" private const val KEY_SEARCHES = "searches_v1" /** - * Pre-vc=59 hard limits. Still used as the absolute upper bound when + * Earlier hard limits. Still used as the absolute upper bound when * Settings.historyWatchesCap is CacheCap.Unlimited — we don't want to * allow truly-uncapped growth that could OOM SP on a hostile import. * Any user-picked cap above this is silently floored to MAX_*_HARD. @@ -75,14 +75,14 @@ class HistoryStore(context: Context) { /** * Bulk import. Callers (currently SettingsImport) feed - * oldest→newest. Single SP write — vc=34 audit flagged the + * oldest→newest. Single SP write audit flagged the * per-row recordWatch in importHistory as a write-storm vector. * * Walks input newest-first (input is fed oldest-first), filters * blanks + already-seen videoIds, prepends to current, then takes * maxWatches(). Imports WIN over older current entries when the - * store is at the cap — the vc=37 first cut silently discarded - * the whole import in that case (round-3 audit HIGH-1). + * store is at the cap — the the first cut silently discarded + * the whole import in that case. * * Skips the SP write when the resulting list is identical (by * reference equality after updateAndGet's no-op return) so a @@ -91,7 +91,7 @@ class HistoryStore(context: Context) { /** * Returns the number of fresh items actually folded into the * store on this call (counts new videoIds; duplicates of - * already-recorded entries don't count). Round-4 audit HIGH-7 — + * already-recorded entries don't count). * SettingsImport previously reported `size_after - size_before` * which lies when the store was at maxWatches() (post-state can * be 50 = pre-state even when 20 imports landed and 20 older @@ -104,7 +104,7 @@ class HistoryStore(context: Context) { val next = _watches.updateAndGet { current -> // Reset the counter inside the CAS lambda so a retry // doesn't accumulate across attempts — same shape as - // SubscriptionsStore.addAll's vc=37 round-3 fix. + // SubscriptionsStore.addAll's round-3 fix. counter.set(0) val seen = HashSet(current.size + items.size) current.forEach { seen.add(it.videoId) } @@ -134,9 +134,8 @@ class HistoryStore(context: Context) { /** * Bulk import for search history. Same pattern as * recordAllWatches — single SP write regardless of input size. - * vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was - * calling recordSearch per row, producing N SP writes on a - * potentially-100k-row import. + * SettingsImport.importHistory previously called recordSearch per + * row, producing N SP writes on a potentially-100k-row import. */ /** * Returns the number of fresh queries actually folded into the diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt index e6cc3aa7a..26ba0e16d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -69,7 +69,6 @@ class PlaylistsStore(context: Context) { * addItem() in a loop — both write SP, and addItem walks every * playlist linearly per insert. A 100-playlist × 100-items * NewPipe export was ~10,001 SP commits + ~10M comparisons. - * Round-4 audit HIGH-2. */ fun importPlaylist(name: String, items: List): Playlist { val stampNow = System.currentTimeMillis() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 9ef0c07bc..ffa02cd5f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -38,7 +38,7 @@ private const val PREFS = "straw_resume_positions" private const val KEY_POSITIONS = "positions_v1" /** - * Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the + * Earlier hard cap. Now a ceiling rather than a fixed value: the * user-picked cap from Settings.resumePositionsCap is silently floored * to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here * than HistoryStore because resume entries are tiny (~50 bytes each) @@ -97,7 +97,7 @@ class ResumePositionsStore(context: Context) { ) val before = _positions.value val next = _positions.updateAndGet { current -> - // Round-67 audit HIGH-6: short-circuit value-equality — + // short-circuit value-equality // a 5s poll tick that finds the same (position, duration, // wall-time) for an existing entry returns `current` // unchanged so the outer `next !== before` guard @@ -117,7 +117,7 @@ class ResumePositionsStore(context: Context) { val withEntry = current + (videoId to entry) // Skip sort+associate when we're under the cap (the // common case at default 500). Sort is O(n log n); - // associate allocates another map. Round-67 audit HIGH-6. + // associate allocates another map. if (withEntry.size > maxResumes()) { // Drop oldest by lastWatchedAt — newcomers always land // because the entry we just added is by definition the @@ -133,8 +133,7 @@ class ResumePositionsStore(context: Context) { if (next !== before) { // JSON encode + SP write off Main — encoding 100k entries // would be ~50-100 ms on a low-end device, and the 5s - // captureResumePosition poll runs on Main. Round-67 - // audit HIGH-6. + // captureResumePosition poll runs on Main. StrawApp.globalScope.launch(Dispatchers.IO) { sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index 56b0d3575..4b9473fe2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -12,7 +12,7 @@ * = ~150 KB worst case. * * Backed by a MutableStateFlow loaded once at construction — - * record()/load() are atomic against concurrent calls. vc=36 audit + * record/load are atomic against concurrent calls. audit * B5: the prior load()→edit()→write() pattern would clobber a * concurrent record() with whichever happened to persist last. * diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index bda38ba0a..7ded93aba 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -72,7 +72,7 @@ enum class AutoUpdateInterval(val label: String) { /** * User-facing cache caps. Each store's hard limit is the cap's value; * `Int.MAX_VALUE` means "unlimited" (the store grows without trimming). - * Defaults match the pre-vc=59 hardcoded constants so existing data + * Defaults match the earlier hardcoded constants so existing data * keeps the same shape until the user picks something different. */ enum class CacheCap(val label: String, val value: Int) { @@ -221,7 +221,7 @@ class SettingsStore(context: Context) { /** * Cached "latest version seen on fdroid" — 0 / "" while none known - * or while caught-up. Lets SettingsScreen show "vc=55 available" + * or while caught-up. Lets SettingsScreen show "an update available" * without re-polling. */ private val _latestKnownVc = MutableStateFlow( @@ -244,7 +244,7 @@ class SettingsStore(context: Context) { * "#shorts" / "#Shorts" / "(shorts)" which most short uploaders * include. * Filter is best-effort — a hand-tagged short with a clean title - * in the subs feed will slip through until vc=57 plumbs an + * in the subs feed will slip through until a future build plumbs an * isShort flag through strawcore-core. */ private val _hideShorts = MutableStateFlow( @@ -258,7 +258,7 @@ class SettingsStore(context: Context) { * takes effect immediately (next write trims to the new cap; reads * are unbounded since they're already in memory). * - * Defaults match the pre-vc=59 hardcoded constants so first-launch + * Defaults match the earlier hardcoded constants so first-launch * behavior is unchanged from prior versions. */ private val _historyWatchesCap = MutableStateFlow( @@ -308,10 +308,9 @@ class SettingsStore(context: Context) { } // Atomic + idempotent. Capture before-state, update in-memory, - // skip the SP write when the value didn't actually change. Round-5 - // audit LOW-1 / MED-2: the prior shape used - // `updateAndGet { r } == r` which is unconditionally true (lambda - // ignores prior) — dead code that confused readers. + // skip the SP write when the value didn't actually change. The + // prior shape used `updateAndGet { r } == r` which is unconditionally + // true (the lambda ignores prior) — dead code that confused readers. fun setMaxResolution(r: MaxResolution) { val before = _maxResolution.value if (before == r) return diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 97a3bf994..f25a6bbc0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -66,7 +66,7 @@ class SubscriptionsStore(context: Context) { /** * Bulk-add. Single persist instead of N. Per-call `toggle()` was - * O(N²) + N SP writes, which the vc=34 security audit flagged as + * O(N²) + N SP writes, which the security audit flagged as * a DoS vector for hostile NewPipe-export imports. Single linear * scan to dedup, one persist regardless of input size. Returns the * count of NEW (not previously-subscribed) channels added so the @@ -76,8 +76,8 @@ class SubscriptionsStore(context: Context) { // Count NEW refs by checking each input URL against the // current state's pre-image inside the CAS lambda. Captures // exactly the additions this call made — concurrent - // toggle()s that race the CAS don't inflate the count (vc=37 - // round-3 audit HIGH-2/CVE-2). The counter lives in an + // toggles that race the CAS don't inflate the count ( + // ). The counter lives in an // AtomicInteger so each lambda re-run resets it correctly. val counter = java.util.concurrent.atomic.AtomicInteger(0) val next = _subs.updateAndGet { state -> diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 1b1ab29ce..c2ea93b5d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -80,7 +80,7 @@ fun ChannelScreen( // the screen recomposes once with A's state before vm.load(B) // resets it. Without this branch we'd render channel A's banner / // name / videos under URL B. Same shape as VideoDetailScreen's - // gate. Round-69 audit HIGH-1. + // gate. state.loadedUrl != channelUrl -> Box( modifier = Modifier.fillMaxSize().statusBarsPadding(), contentAlignment = Alignment.Center, @@ -218,8 +218,8 @@ private fun ChannelVideoRow( // Don't repeat duration here — VideoThumbnail's // bottom-right badge already shows it. Add the upload // date so the row reads 'N views · 2 days ago' the way - // YT renders it. vc=65 — Cobb caught the duplicate - // duration + missing date on the channel page. + // YT renders it. The earlier row was duplicating duration + // and missing the upload date on the channel page. val meta = buildString { if (item.viewCount > 0) append("${formatCount(item.viewCount)} views") if (item.uploadDateRelative.isNotBlank()) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index fa2dbd404..5415f2c95 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -36,7 +36,7 @@ data class ChannelUiState( * frame before vm.load(B) clears it. Without this field, any * caller that derives "this is the channel we want" from * `state.name` (or other display fields) is reading channel A's - * data while believing it's B. Round-68 audit MED-4. + * data while believing it's B. */ val loadedUrl: String? = null, ) @@ -47,20 +47,20 @@ class ChannelViewModel : ViewModel() { // Track the active load coroutine — same shape as // VideoDetailViewModel. Rapid channel switches no longer race; - // the late-arriving older fetch is cancelled. Round-4 audit - // HIGH-2 / MED-1. + // the late-arriving older fetch is cancelled. + // / MED-1. private var inFlight: Job? = null fun load(channelUrl: String) { - // Snapshot _ui once so the two reads agree. Round-68 audit MED-4. + // Snapshot _ui once so the two reads agree. val snap = _ui.value if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return - // Round-5 audit MED-3: extractor-emitted uploaderUrl can be + // extractor-emitted uploaderUrl can be // attacker-controlled if the YT response is poisoned upstream. // Refuse non-YT hosts at the entry point so we don't even - // issue a network call to evil.com via strawcore. Round-6 - // audit HIGH-1: also cancel inFlight on rejection so a - // still-resolving prior load can't clobber the error banner. + // issue a network call to evil.com via strawcore. Also cancel + // inFlight on rejection so a still-resolving prior load can't + // clobber the error banner. if (!isAllowedYtUrl(channelUrl)) { inFlight?.cancel() inFlight = null @@ -110,8 +110,7 @@ class ChannelViewModel : ViewModel() { loading = false, // Scrub before storing — UniFFI/Rust exceptions // can embed full signed googlevideo URLs in the - // message (NetworkError::Recaptcha { url }). vc=37 - // round-3 audit CVE-1. + // message (NetworkError::Recaptcha { url }). error = com.sulkta.straw.util.LogDump.scrubLine( t.message ?: t.javaClass.simpleName, ), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index ed0c0e044..a4d7e4fc4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -103,8 +103,7 @@ object SettingsImport { private const val YT_SERVICE_ID = 0 // The allowlist itself lives in util.YtUrl now — VideoDetailViewModel - // also gates auto-channelInfo + recordWatch through it. Round-4 - // audit HIGH-4 / HIGH-5. + // also gates auto-channelInfo + recordWatch through it. private fun isAllowedYtUrl(url: String): Boolean = com.sulkta.straw.util.isAllowedYtUrl(url) @@ -113,7 +112,7 @@ object SettingsImport { // runInner is suspend (it switches to NonCancellable for // cleanup). Plain runCatching would swallow a user-back // CancellationException and surface it as a normal - // failure with a misleading banner. Round-6 audit HIGH-2. + // failure with a misleading banner. com.sulkta.straw.util.runCatchingCancellable { runInner(context, zipUri) } @@ -121,7 +120,7 @@ object SettingsImport { /** * Sweep stale import work-dirs left behind by a previous run that - * was killed mid-extraction. CRIT from the vc=34 security audit: + * was killed mid-extraction. CRIT from the security audit: * a force-killed import leaves the user's full newpipe.db sitting * in cacheDir indefinitely. StrawApp.onCreate calls this on every * cold start. @@ -203,7 +202,7 @@ object SettingsImport { // Reject duplicate entries — a malicious zip // can put a benign db first and a hostile // second; ZipInputStream walks in order and - // would overwrite. Round-6 audit MED-5. + // would overwrite. if (dbFile != null) { warnings += "duplicate newpipe.db in archive — aborting" return null to null @@ -322,8 +321,7 @@ object SettingsImport { openDb(dbFile).use { db -> val playlistRows = mutableListOf>() // Hard caps so a malicious export with millions of rows - // doesn't walk an unbounded cursor into memory. Round-6 - // audit MED-3. + // doesn't walk an unbounded cursor into memory. db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c -> while (c.moveToNext()) { val uid = c.getLong(0) @@ -362,7 +360,7 @@ object SettingsImport { // instead of (1 create + N addItem) writes. Old shape // produced ~10k SP commits on a 100×100 export, plus // O(N²) work in addItem's per-call linear scan over - // every playlist. Round-4 audit HIGH-2. + // every playlist. store.importPlaylist(name, items) playlistsAdded++ itemsAdded += items.size @@ -390,7 +388,7 @@ object SettingsImport { openDb(dbFile).use { db -> // Search history — feed oldest first so the store ends up with // the most-recent on top after its own dedup + take(MAX). - // Stage + bulk-write — vc=37 round-3 audit CVE MED-6: + // Stage + bulk-write —: // per-row recordSearch was N SP writes on potentially // 100k+ rows. The SELECT also lacked a LIMIT; added now. val stagedSearches = mutableListOf() @@ -458,7 +456,7 @@ object SettingsImport { // recordAllWatches / recordAllSearches return the real // added count (counts fresh videoIds / queries that landed, // ignoring duplicates and pre-saturated-store truncation). - // Round-4 audit HIGH-7 / MED-2 — previous size_after - + // / MED-2 — previous size_after // size_before reported 0 when the store was already at cap // even when 20 fresh imports actually landed. return HistResult( @@ -496,7 +494,6 @@ object SettingsImport { // changed something. Prior shape counted every // observed key, inflating the import summary to // "12 settings applied" when only 2 changed. - // Round-6 audit MED-2. if (want != have) { settings.toggle(cat) applied++ diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 87f9e2b3e..6c726aff2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -264,8 +264,8 @@ fun VideoDetailScreen( // vm.load(B)'s reset propagates. Without this gate, the // InlinePlayer's LaunchedEffect would fire with // streamUrl=B but resolved=A's URLs and play A under - // B's chrome (Cobb-reported 2026-05-26: detail page - // shows new video, audio is the old one). + // B's chrome — symptom is the detail page showing the + // new video while the audio is still the old one. if (state.loadedUrl != streamUrl) return@Column // Player surface — edge-to-edge, NewPipe/YouTube style. // Lives outside the 16dp horizontal padding so the @@ -485,7 +485,7 @@ fun VideoDetailScreen( } // PiP into nothing isn't useful — bail with a // Toast if there's no controller / no resolved - // playback to push into it. vc=34 audit Q-13. + // playback to push into it. val c = controller val r = state.resolved if (c == null || r == null) { @@ -715,7 +715,7 @@ private fun RelatedRow( // the uploader name on each row — it's implicit. Skip // empty pieces with the leading-separator dance so we // never end up with " · viewCount" or trailing dots. - // vc=64 — Cobb caught the empty metadata line on + // Earlier shape was leaving an empty metadata line on // More-from-channel rows. val meta = buildString { if (item.uploader.isNotBlank()) append(item.uploader) @@ -770,7 +770,7 @@ private fun InlinePlayer( // retryVersion lets the user manually re-fire setPlayingFrom after // a playback error. Without it, the screen used to lock into the // thumbnail+spinner branch once NowPlaying.clear() fired from - // onPlayerError. vc=62 audit BUG-2. + // onPlayerError. val resolved = state.resolved var retryVersion by remember(streamUrl) { mutableIntStateOf(0) } LaunchedEffect(controller, resolved, streamUrl, retryVersion) { @@ -794,12 +794,11 @@ private fun InlinePlayer( val listener = object : Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { // Scrub the message — Media3's HttpDataSource exceptions - // include the full signed URL in .message. vc=36 audit - // CVE HIGH-1. + // include the full signed URL in.message. val raw = error.message ?: "(no message)" playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}" // Clear NowPlaying so the minibar drops the dead - // session. vc=36 audit MED-3. + // session. NowPlaying.clear() } } @@ -834,7 +833,7 @@ private fun InlinePlayer( Spacer(modifier = Modifier.height(12.dp)) OutlinedButton(onClick = { // Clear the error AND nudge the LaunchedEffect to - // re-attempt setPlayingFrom. vc=62 audit BUG-2 — + // re-attempt setPlayingFrom. // without this the screen used to lock on the // error forever after NowPlaying.clear(). playbackError = null diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index df48c4fb4..d6038f9b9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -100,7 +100,7 @@ data class VideoDetailUiState( * vm.load(B) clears it. Without this field, the InlinePlayer's * setPlayingFrom would fire with streamUrl=B but resolved=A's * playback URLs — claiming NowPlaying with B's streamUrl but - * playing A's video under it. vc=63 audit. + * playing A's video under it. audit. */ val loadedUrl: String? = null, ) @@ -112,7 +112,7 @@ class VideoDetailViewModel : ViewModel() { // Track the active load coroutine so a rapid tap to a different video // cancels the prior fetch; otherwise a slow-to-finish older load // overwrites the newer state and the player ends up streaming A while - // the detail UI shows B. Round-4 audit HIGH-2. + // the detail UI shows B. private var inFlight: Job? = null fun load(streamUrl: String) { @@ -123,11 +123,11 @@ class VideoDetailViewModel : ViewModel() { if (snap.loadedUrl == streamUrl && snap.detail != null) return // Same YT-host gate as ChannelViewModel — covers the case // where a tap on a poisoned related-card lands here. - // Round-5 audit MED-3. Round-6 audit HIGH-1: cancel any + // cancel any // in-flight load on rejection too — otherwise the // late-arriving prior-job's fence still PASSES (loadedUrl // wasn't moved) and clobbers the "Unsupported URL" error - // banner. round-67 audit HIGH-7: also set loadedUrl on this + // banner.: also set loadedUrl on this // path so the gate reads coherently for any caller that // checks _ui.value.loadedUrl on the rejected path. if (!isAllowedYtUrl(streamUrl)) { @@ -155,12 +155,11 @@ class VideoDetailViewModel : ViewModel() { // Move SP write off the main coroutine — recordWatch // JSON-encodes the watch list (up to 50 entries) + - // sp.edit().apply(). Small but synchronous; vc=36 + // sp.edit.apply. Small but synchronous; // audit Q9. Only record when the resolved URL passes // the YT allowlist — otherwise extractor-emitted // non-YT URLs (poisoned related/moreFromChannel) end // up in Recent Watches and survive process death. - // Round-4 audit HIGH-5. if (isAllowedYtUrl(streamUrl)) { withContext(Dispatchers.IO) { runCatchingCancellable { @@ -216,9 +215,9 @@ class VideoDetailViewModel : ViewModel() { // Gate the auto-fetch behind the same YT-host allowlist // we apply to imports: a poisoned uploaderUrl from the // extractor would otherwise trigger an arbitrary-host - // network call. Round-4 audit HIGH-4. + // network call. // - // Round-69 audit MED-3: validate once and persist the + // validate once and persist the // SAFE value into VideoDetail.uploaderUrl so downstream // consumers (NowPlaying → PlaybackService autoplay, // queue, etc.) inherit the validated string instead @@ -246,7 +245,7 @@ class VideoDetailViewModel : ViewModel() { // extractor surfaces the URL string verbatim // and a poisoned channel page could ship // `data:image/svg+xml,... - - Apache License - Version 2.0, January 2004 - - -

Apache License
Version 2.0, January 2004
- http://www.apache.org/licenses/

-

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

-

1. Definitions.

-

"License" shall mean the terms and conditions for use, reproduction, and - distribution as defined by Sections 1 through 9 of this document.

-

"Licensor" shall mean the copyright owner or entity authorized by the - copyright owner that is granting the License.

-

"Legal Entity" shall mean the union of the acting entity and all other - entities that control, are controlled by, or are under common control with - that entity. For the purposes of this definition, "control" means (i) the - power, direct or indirect, to cause the direction or management of such - entity, whether by contract or otherwise, or (ii) ownership of fifty - percent (50%) or more of the outstanding shares, or (iii) beneficial - ownership of such entity.

-

"You" (or "Your") shall mean an individual or Legal Entity exercising - permissions granted by this License.

-

"Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation source, - and configuration files.

-

"Object" form shall mean any form resulting from mechanical transformation - or translation of a Source form, including but not limited to compiled - object code, generated documentation, and conversions to other media types.

-

"Work" shall mean the work of authorship, whether in Source or Object form, - made available under the License, as indicated by a copyright notice that - is included in or attached to the work (an example is provided in the - Appendix below).

-

"Derivative Works" shall mean any work, whether in Source or Object form, - that is based on (or derived from) the Work and for which the editorial - revisions, annotations, elaborations, or other modifications represent, as - a whole, an original work of authorship. For the purposes of this License, - Derivative Works shall not include works that remain separable from, or - merely link (or bind by name) to the interfaces of, the Work and Derivative - Works thereof.

-

"Contribution" shall mean any work of authorship, including the original - version of the Work and any modifications or additions to that Work or - Derivative Works thereof, that is intentionally submitted to Licensor for - inclusion in the Work by the copyright owner or by an individual or Legal - Entity authorized to submit on behalf of the copyright owner. For the - purposes of this definition, "submitted" means any form of electronic, - verbal, or written communication sent to the Licensor or its - representatives, including but not limited to communication on electronic - mailing lists, source code control systems, and issue tracking systems that - are managed by, or on behalf of, the Licensor for the purpose of discussing - and improving the Work, but excluding communication that is conspicuously - marked or otherwise designated in writing by the copyright owner as "Not a - Contribution."

-

"Contributor" shall mean Licensor and any individual or Legal Entity on - behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work.

-

2. Grant of Copyright License. Subject to the - terms and conditions of this License, each Contributor hereby grants to You - a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, publicly - display, publicly perform, sublicense, and distribute the Work and such - Derivative Works in Source or Object form.

-

3. Grant of Patent License. Subject to the terms - and conditions of this License, each Contributor hereby grants to You a - perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, use, - offer to sell, sell, import, and otherwise transfer the Work, where such - license applies only to those patent claims licensable by such Contributor - that are necessarily infringed by their Contribution(s) alone or by - combination of their Contribution(s) with the Work to which such - Contribution(s) was submitted. If You institute patent litigation against - any entity (including a cross-claim or counterclaim in a lawsuit) alleging - that the Work or a Contribution incorporated within the Work constitutes - direct or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate as of the - date such litigation is filed.

-

4. Redistribution. You may reproduce and - distribute copies of the Work or Derivative Works thereof in any medium, - with or without modifications, and in Source or Object form, provided that - You meet the following conditions:

-
    -
  1. You must give any other recipients of the Work or Derivative Works a - copy of this License; and
  2. - -
  3. You must cause any modified files to carry prominent notices stating - that You changed the files; and
  4. - -
  5. You must retain, in the Source form of any Derivative Works that You - distribute, all copyright, patent, trademark, and attribution notices from - the Source form of the Work, excluding those notices that do not pertain to - any part of the Derivative Works; and
  6. - -
  7. If the Work includes a "NOTICE" text file as part of its distribution, - then any Derivative Works that You distribute must include a readable copy - of the attribution notices contained within such NOTICE file, excluding - those notices that do not pertain to any part of the Derivative Works, in - at least one of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or documentation, - if provided along with the Derivative Works; or, within a display generated - by the Derivative Works, if and wherever such third-party notices normally - appear. The contents of the NOTICE file are for informational purposes only - and do not modify the License. You may add Your own attribution notices - within Derivative Works that You distribute, alongside or as an addendum to - the NOTICE text from the Work, provided that such additional attribution - notices cannot be construed as modifying the License. -
    -
    - You may add Your own copyright statement to Your modifications and may - provide additional or different license terms and conditions for use, - reproduction, or distribution of Your modifications, or for any such - Derivative Works as a whole, provided Your use, reproduction, and - distribution of the Work otherwise complies with the conditions stated in - this License. -
  8. - -
- -

5. Submission of Contributions. Unless You - explicitly state otherwise, any Contribution intentionally submitted for - inclusion in the Work by You to the Licensor shall be under the terms and - conditions of this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify the - terms of any separate license agreement you may have executed with Licensor - regarding such Contributions.

-

6. Trademarks. This License does not grant - permission to use the trade names, trademarks, service marks, or product - names of the Licensor, except as required for reasonable and customary use - in describing the origin of the Work and reproducing the content of the - NOTICE file.

-

7. Disclaimer of Warranty. Unless required by - applicable law or agreed to in writing, Licensor provides the Work (and - each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT - WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, - without limitation, any warranties or conditions of TITLE, - NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You - are solely responsible for determining the appropriateness of using or - redistributing the Work and assume any risks associated with Your exercise - of permissions under this License.

-

8. Limitation of Liability. In no event and - under no legal theory, whether in tort (including negligence), contract, or - otherwise, unless required by applicable law (such as deliberate and - grossly negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a result - of this License or out of the use or inability to use the Work (including - but not limited to damages for loss of goodwill, work stoppage, computer - failure or malfunction, or any and all other commercial damages or losses), - even if such Contributor has been advised of the possibility of such - damages.

-

9. Accepting Warranty or Additional Liability. - While redistributing the Work or Derivative Works thereof, You may choose - to offer, and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this License. - However, in accepting such obligations, You may act only on Your own behalf - and on Your sole responsibility, not on behalf of any other Contributor, - and only if You agree to indemnify, defend, and hold each Contributor - harmless for any liability incurred by, or claims asserted against, such - Contributor by reason of your accepting any such warranty or additional - liability.

- - \ No newline at end of file diff --git a/app/src/main/assets/epl1.html b/app/src/main/assets/epl1.html deleted file mode 100644 index 7123552dd..000000000 --- a/app/src/main/assets/epl1.html +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - - Eclipse Public License - Version 1.0 - - - - - -

Eclipse Public License - v 1.0

- -

THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE - PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR - DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS - AGREEMENT.

- -

1. DEFINITIONS

- -

"Contribution" means:

- -

a) in the case of the initial Contributor, the initial - code and documentation distributed under this Agreement, and

-

b) in the case of each subsequent Contributor:

-

i) changes to the Program, and

-

ii) additions to the Program;

-

where such changes and/or additions to the Program - originate from and are distributed by that particular Contributor. A - Contribution 'originates' from a Contributor if it was added to the - Program by such Contributor itself or anyone acting on such - Contributor's behalf. Contributions do not include additions to the - Program which: (i) are separate modules of software distributed in - conjunction with the Program under their own license agreement, and (ii) - are not derivative works of the Program.

- -

"Contributor" means any person or entity that distributes - the Program.

- -

"Licensed Patents" mean patent claims licensable by a - Contributor which are necessarily infringed by the use or sale of its - Contribution alone or when combined with the Program.

- -

"Program" means the Contributions distributed in accordance - with this Agreement.

- -

"Recipient" means anyone who receives the Program under - this Agreement, including all Contributors.

- -

2. GRANT OF RIGHTS

- -

a) Subject to the terms of this Agreement, each - Contributor hereby grants Recipient a non-exclusive, worldwide, - royalty-free copyright license to reproduce, prepare derivative works - of, publicly display, publicly perform, distribute and sublicense the - Contribution of such Contributor, if any, and such derivative works, in - source code and object code form.

- -

b) Subject to the terms of this Agreement, each - Contributor hereby grants Recipient a non-exclusive, worldwide, - royalty-free patent license under Licensed Patents to make, use, sell, - offer to sell, import and otherwise transfer the Contribution of such - Contributor, if any, in source code and object code form. This patent - license shall apply to the combination of the Contribution and the - Program if, at the time the Contribution is added by the Contributor, - such addition of the Contribution causes such combination to be covered - by the Licensed Patents. The patent license shall not apply to any other - combinations which include the Contribution. No hardware per se is - licensed hereunder.

- -

c) Recipient understands that although each Contributor - grants the licenses to its Contributions set forth herein, no assurances - are provided by any Contributor that the Program does not infringe the - patent or other intellectual property rights of any other entity. Each - Contributor disclaims any liability to Recipient for claims brought by - any other entity based on infringement of intellectual property rights - or otherwise. As a condition to exercising the rights and licenses - granted hereunder, each Recipient hereby assumes sole responsibility to - secure any other intellectual property rights needed, if any. For - example, if a third party patent license is required to allow Recipient - to distribute the Program, it is Recipient's responsibility to acquire - that license before distributing the Program.

- -

d) Each Contributor represents that to its knowledge it - has sufficient copyright rights in its Contribution, if any, to grant - the copyright license set forth in this Agreement.

- -

3. REQUIREMENTS

- -

A Contributor may choose to distribute the Program in object code - form under its own license agreement, provided that:

- -

a) it complies with the terms and conditions of this - Agreement; and

- -

b) its license agreement:

- -

i) effectively disclaims on behalf of all Contributors - all warranties and conditions, express and implied, including warranties - or conditions of title and non-infringement, and implied warranties or - conditions of merchantability and fitness for a particular purpose;

- -

ii) effectively excludes on behalf of all Contributors - all liability for damages, including direct, indirect, special, - incidental and consequential damages, such as lost profits;

- -

iii) states that any provisions which differ from this - Agreement are offered by that Contributor alone and not by any other - party; and

- -

iv) states that source code for the Program is available - from such Contributor, and informs licensees how to obtain it in a - reasonable manner on or through a medium customarily used for software - exchange.

- -

When the Program is made available in source code form:

- -

a) it must be made available under this Agreement; and

- -

b) a copy of this Agreement must be included with each - copy of the Program.

- -

Contributors may not remove or alter any copyright notices contained - within the Program.

- -

Each Contributor must identify itself as the originator of its - Contribution, if any, in a manner that reasonably allows subsequent - Recipients to identify the originator of the Contribution.

- -

4. COMMERCIAL DISTRIBUTION

- -

Commercial distributors of software may accept certain - responsibilities with respect to end users, business partners and the - like. While this license is intended to facilitate the commercial use of - the Program, the Contributor who includes the Program in a commercial - product offering should do so in a manner which does not create - potential liability for other Contributors. Therefore, if a Contributor - includes the Program in a commercial product offering, such Contributor - ("Commercial Contributor") hereby agrees to defend and - indemnify every other Contributor ("Indemnified Contributor") - against any losses, damages and costs (collectively "Losses") - arising from claims, lawsuits and other legal actions brought by a third - party against the Indemnified Contributor to the extent caused by the - acts or omissions of such Commercial Contributor in connection with its - distribution of the Program in a commercial product offering. The - obligations in this section do not apply to any claims or Losses - relating to any actual or alleged intellectual property infringement. In - order to qualify, an Indemnified Contributor must: a) promptly notify - the Commercial Contributor in writing of such claim, and b) allow the - Commercial Contributor to control, and cooperate with the Commercial - Contributor in, the defense and any related settlement negotiations. The - Indemnified Contributor may participate in any such claim at its own - expense.

- -

For example, a Contributor might include the Program in a commercial - product offering, Product X. That Contributor is then a Commercial - Contributor. If that Commercial Contributor then makes performance - claims, or offers warranties related to Product X, those performance - claims and warranties are such Commercial Contributor's responsibility - alone. Under this section, the Commercial Contributor would have to - defend claims against the other Contributors related to those - performance claims and warranties, and if a court requires any other - Contributor to pay any damages as a result, the Commercial Contributor - must pay those damages.

- -

5. NO WARRANTY

- -

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS - PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS - OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, - ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY - OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely - responsible for determining the appropriateness of using and - distributing the Program and assumes all risks associated with its - exercise of rights under this Agreement , including but not limited to - the risks and costs of program errors, compliance with applicable laws, - damage to or loss of data, programs or equipment, and unavailability or - interruption of operations.

- -

6. DISCLAIMER OF LIABILITY

- -

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT - NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, - INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING - WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF - LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR - DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED - HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

- -

7. GENERAL

- -

If any provision of this Agreement is invalid or unenforceable under - applicable law, it shall not affect the validity or enforceability of - the remainder of the terms of this Agreement, and without further action - by the parties hereto, such provision shall be reformed to the minimum - extent necessary to make such provision valid and enforceable.

- -

If Recipient institutes patent litigation against any entity - (including a cross-claim or counterclaim in a lawsuit) alleging that the - Program itself (excluding combinations of the Program with other - software or hardware) infringes such Recipient's patent(s), then such - Recipient's rights granted under Section 2(b) shall terminate as of the - date such litigation is filed.

- -

All Recipient's rights under this Agreement shall terminate if it - fails to comply with any of the material terms or conditions of this - Agreement and does not cure such failure in a reasonable period of time - after becoming aware of such noncompliance. If all Recipient's rights - under this Agreement terminate, Recipient agrees to cease use and - distribution of the Program as soon as reasonably practicable. However, - Recipient's obligations under this Agreement and any licenses granted by - Recipient relating to the Program shall continue and survive.

- -

Everyone is permitted to copy and distribute copies of this - Agreement, but in order to avoid inconsistency the Agreement is - copyrighted and may only be modified in the following manner. The - Agreement Steward reserves the right to publish new versions (including - revisions) of this Agreement from time to time. No one other than the - Agreement Steward has the right to modify this Agreement. The Eclipse - Foundation is the initial Agreement Steward. The Eclipse Foundation may - assign the responsibility to serve as the Agreement Steward to a - suitable separate entity. Each new version of the Agreement will be - given a distinguishing version number. The Program (including - Contributions) may always be distributed subject to the version of the - Agreement under which it was received. In addition, after a new version - of the Agreement is published, Contributor may elect to distribute the - Program (including its Contributions) under the new version. Except as - expressly stated in Sections 2(a) and 2(b) above, Recipient receives no - rights or licenses to the intellectual property of any Contributor under - this Agreement, whether expressly, by implication, estoppel or - otherwise. All rights in the Program not expressly granted under this - Agreement are reserved.

- -

This Agreement is governed by the laws of the State of New York and - the intellectual property laws of the United States of America. No party - to this Agreement will bring a legal action under this Agreement more - than one year after the cause of action arose. Each party waives its - rights to a jury trial in any resulting litigation.

- - - - \ No newline at end of file diff --git a/app/src/main/assets/gpl_3.html b/app/src/main/assets/gpl_3.html deleted file mode 100644 index 7e885a640..000000000 --- a/app/src/main/assets/gpl_3.html +++ /dev/null @@ -1,639 +0,0 @@ - - - - - - GNU General Public License v3.0 - GNU Project - Free Software Foundation (FSF) - - - -

GNU GENERAL PUBLIC LICENSE

-

Version 3, 29 June 2007

- -

Copyright © 2007 Free Software Foundation, Inc. - <http://fsf.org/>

- Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed.

- -

Preamble

- -

The GNU General Public License is a free, copyleft license for -software and other kinds of works.

- -

The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too.

- -

When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things.

- -

To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others.

- -

For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights.

- -

Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it.

- -

For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions.

- -

Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users.

- -

Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free.

- -

The precise terms and conditions for copying, distribution and -modification follow.

- -

TERMS AND CONDITIONS

- -

0. Definitions.

- -

“This License” refers to version 3 of the GNU General Public License.

- -

“Copyright” also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks.

- -

“The Program” refers to any copyrightable work licensed under this -License. Each licensee is addressed as “you”. “Licensees” and -“recipients” may be individuals or organizations.

- -

To “modify” a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a “modified version” of the -earlier work or a work “based on” the earlier work.

- -

A “covered work” means either the unmodified Program or a work based -on the Program.

- -

To “propagate” a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well.

- -

To “convey” a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying.

- -

An interactive user interface displays “Appropriate Legal Notices” -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion.

- -

1. Source Code.

- -

The “source code” for a work means the preferred form of the work -for making modifications to it. “Object code” means any non-source -form of a work.

- -

A “Standard Interface” means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language.

- -

The “System Libraries” of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -“Major Component”, in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it.

- -

The “Corresponding Source” for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work.

- -

The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source.

- -

The Corresponding Source for a work in source code form is that -same work.

- -

2. Basic Permissions.

- -

All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law.

- -

You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you.

- -

Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary.

- -

3. Protecting Users' Legal Rights From Anti-Circumvention Law.

- -

No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures.

- -

When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures.

- -

4. Conveying Verbatim Copies.

- -

You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program.

- -

You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee.

- -

5. Conveying Modified Source Versions.

- -

You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions:

- -
    -
  • a) The work must carry prominent notices stating that you modified - it, and giving a relevant date.
  • - -
  • b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - “keep intact all notices”.
  • - -
  • c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it.
  • - -
  • d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so.
  • -
- -

A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -“aggregate” if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate.

- -

6. Conveying Non-Source Forms.

- -

You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways:

- -
    -
  • a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange.
  • - -
  • b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge.
  • - -
  • c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b.
  • - -
  • d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements.
  • - -
  • e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d.
  • -
- -

A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work.

- -

A “User Product” is either (1) a “consumer product”, which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, “normally used” refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product.

- -

“Installation Information” for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made.

- -

If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM).

- -

The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network.

- -

Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying.

- -

7. Additional Terms.

- -

“Additional permissions” are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions.

- -

When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission.

- -

Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms:

- -
    -
  • a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or
  • - -
  • b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or
  • - -
  • c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or
  • - -
  • d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or
  • - -
  • e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or
  • - -
  • f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors.
  • -
- -

All other non-permissive additional terms are considered “further -restrictions” within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying.

- -

If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms.

- -

Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way.

- -

8. Termination.

- -

You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11).

- -

However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation.

- -

Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice.

- -

Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10.

- -

9. Acceptance Not Required for Having Copies.

- -

You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so.

- -

10. Automatic Licensing of Downstream Recipients.

- -

Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License.

- -

An “entity transaction” is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts.

- -

You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it.

- -

11. Patents.

- -

A “contributor” is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's “contributor version”.

- -

A contributor's “essential patent claims” are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, “control” includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License.

- -

Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version.

- -

In the following three paragraphs, a “patent license” is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To “grant” such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party.

- -

If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. “Knowingly relying” means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid.

- -

If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it.

- -

A patent license is “discriminatory” if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007.

- -

Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law.

- -

12. No Surrender of Others' Freedom.

- -

If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program.

- -

13. Use with the GNU Affero General Public License.

- -

Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such.

- -

14. Revised Versions of this License.

- -

The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns.

- -

Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License “or any later version” applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation.

- -

If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program.

- -

Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version.

- -

15. Disclaimer of Warranty.

- -

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

- -

16. Limitation of Liability.

- -

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES.

- -

17. Interpretation of Sections 15 and 16.

- -

If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee.

- - diff --git a/app/src/main/assets/mit.html b/app/src/main/assets/mit.html deleted file mode 100644 index 909d61acb..000000000 --- a/app/src/main/assets/mit.html +++ /dev/null @@ -1,26 +0,0 @@ - - - -

Copyright (c) <year> <copyright holders>

- -

Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions:

- -

-The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software.

-

-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.
-NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

- - diff --git a/app/src/main/assets/mpl2.html b/app/src/main/assets/mpl2.html deleted file mode 100644 index 5e988a70c..000000000 --- a/app/src/main/assets/mpl2.html +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - Mozilla Public License, version 2.0 - - -

Mozilla Public License
Version 2.0

-

1. Definitions

-
-
1.1. “Contributor”
-

means each individual or legal entity that creates, contributes to the creation of, or - owns Covered Software.

-
-
1.2. “Contributor Version”
-

means the combination of the Contributions of others (if any) used by a Contributor and - that particular Contributor’s Contribution.

-
-
1.3. “Contribution”
-

means Covered Software of a particular Contributor.

-
-
1.4. “Covered Software”
-

means Source Code Form to which the initial Contributor has attached the notice in - Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source - Code Form, in each case including portions thereof.

-
-
1.5. “Incompatible With Secondary Licenses”
-

means

-
    -
  1. that the initial Contributor has attached the notice described in Exhibit B to - the Covered Software; or

  2. -
  3. that the Covered Software was made available under the terms of version 1.1 or - earlier of the License, but not also under the terms of a Secondary License.

    -
  4. -
-
-
1.6. “Executable Form”
-

means any form of the work other than Source Code Form.

-
-
1.7. “Larger Work”
-

means a work that combines Covered Software with other material, in a separate file or - files, that is not Covered Software.

-
-
1.8. “License”
-

means this document.

-
-
1.9. “Licensable”
-

means having the right to grant, to the maximum extent possible, whether at the time of - the initial grant or subsequently, any and all of the rights conveyed by this License.

-
-
1.10. “Modifications”
-

means any of the following:

-
    -
  1. any file in Source Code Form that results from an addition to, deletion from, or - modification of the contents of Covered Software; or

  2. -
  3. any new file in Source Code Form that contains any Covered Software.

  4. -
-
-
1.11. “Patent Claims” of a Contributor
-

means any patent claim(s), including without limitation, method, process, and apparatus - claims, in any patent Licensable by such Contributor that would be infringed, but for the - grant of the License, by the making, using, selling, offering for sale, having made, import, - or transfer of either its Contributions or its Contributor Version.

-
-
1.12. “Secondary License”
-

means either the GNU General Public License, Version 2.0, the GNU Lesser General Public - License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later - versions of those licenses.

-
-
1.13. “Source Code Form”
-

means the form of the work preferred for making modifications.

-
-
1.14. “You” (or “Your”)
-

means an individual or a legal entity exercising rights under this License. For legal - entities, “You” includes any entity that controls, is controlled by, or is under common - control with You. For purposes of this definition, “control” means (a) the power, direct or - indirect, to cause the direction or management of such entity, whether by contract or - otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or - beneficial ownership of such entity.

-
-
-

2. License Grants and Conditions

-

2.1. Grants

-

Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license:

-
    -
  1. under intellectual property rights (other than patent or trademark) Licensable by such - Contributor to use, reproduce, make available, modify, display, perform, distribute, and - otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and

  2. -
  3. under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, - import, and otherwise transfer either its Contributions or its Contributor Version.

  4. -
-

2.2. Effective Date

-

The licenses granted in Section 2.1 with respect to any Contribution become effective for - each Contribution on the date the Contributor first distributes such Contribution.

-

2.3. Limitations on Grant Scope

-

The licenses granted in this Section 2 are the only rights granted under this License. No - additional rights or licenses will be implied from the distribution or licensing of Covered - Software under this License. Notwithstanding Section 2.1(b) above, no patent license is - granted by a Contributor:

-
    -
  1. for any code that a Contributor has removed from Covered Software; or

  2. -
  3. for infringements caused by: (i) Your and any other third party’s modifications of - Covered Software, or (ii) the combination of its Contributions with other software (except - as part of its Contributor Version); or

  4. -
  5. under Patent Claims infringed by Covered Software in the absence of its - Contributions.

  6. -
-

This License does not grant any rights in the trademarks, service marks, or logos of any - Contributor (except as may be necessary to comply with the notice requirements in Section 3.4).

-

2.4. Subsequent Licenses

-

No Contributor makes additional grants as a result of Your choice to distribute the Covered - Software under a subsequent version of this License (see Section 10.2) or under the terms - of a Secondary License (if permitted under the terms of Section 3.3).

-

2.5. Representation

-

Each Contributor represents that the Contributor believes its Contributions are its original - creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by - this License.

-

2.6. Fair Use

-

This License is not intended to limit any rights You have under applicable copyright doctrines of - fair use, fair dealing, or other equivalents.

-

2.7. Conditions

-

Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.

-

3. Responsibilities

-

3.1. Distribution of Source Form

-

All distribution of Covered Software in Source Code Form, including any Modifications that You - create or to which You contribute, must be under the terms of this License. You must inform - recipients that the Source Code Form of the Covered Software is governed by the terms of this - License, and how they can obtain a copy of this License. You may not attempt to alter or - restrict the recipients’ rights in the Source Code Form.

-

3.2. Distribution of Executable Form

-

If You distribute Covered Software in Executable Form then:

-
    -
  1. such Covered Software must also be made available in Source Code Form, as described in - Section 3.1, and You must inform recipients of the Executable Form how they can obtain - a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and

  2. -
  3. You may distribute such Executable Form under the terms of this License, or sublicense it - under different terms, provided that the license for the Executable Form does not attempt to - limit or alter the recipients’ rights in the Source Code Form under this License.

  4. -
-

3.3. Distribution of a Larger Work

-

You may create and distribute a Larger Work under terms of Your choice, provided that You also - comply with the requirements of this License for the Covered Software. If the Larger Work is a - combination of Covered Software with a work governed by one or more Secondary Licenses, and the - Covered Software is not Incompatible With Secondary Licenses, this License permits You to - additionally distribute such Covered Software under the terms of such Secondary License(s), so - that the recipient of the Larger Work may, at their option, further distribute the Covered - Software under the terms of either this License or such Secondary License(s).

-

3.4. Notices

-

You may not remove or alter the substance of any license notices (including copyright notices, - patent notices, disclaimers of warranty, or limitations of liability) contained within the - Source Code Form of the Covered Software, except that You may alter any license notices to the - extent required to remedy known factual inaccuracies.

-

3.5. Application of Additional Terms

-

You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability - obligations to one or more recipients of Covered Software. However, You may do so only on Your - own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any - such warranty, support, indemnity, or liability obligation is offered by You alone, and You - hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a - result of warranty, support, indemnity or liability terms You offer. You may include additional - disclaimers of warranty and limitations of liability specific to any jurisdiction.

-

4. Inability to Comply Due to Statute or - Regulation

-

If it is impossible for You to comply with any of the terms of this License with respect to some - or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) - comply with the terms of this License to the maximum extent possible; and (b) describe the - limitations and the code they affect. Such description must be placed in a text file included - with all distributions of the Covered Software under this License. Except to the extent - prohibited by statute or regulation, such description must be sufficiently detailed for a - recipient of ordinary skill to be able to understand it.

-

5. Termination

-

5.1. The rights granted under this License will terminate automatically if You fail to comply - with any of its terms. However, if You become compliant, then the rights granted under this - License from a particular Contributor are reinstated (a) provisionally, unless and until such - Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such - Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days - after You have come back into compliance. Moreover, Your grants from a particular Contributor - are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by - some reasonable means, this is the first time You have received notice of non-compliance with - this License from such Contributor, and You become compliant prior to 30 days after Your receipt - of the notice.

-

5.2. If You initiate litigation against any entity by asserting a patent infringement claim - (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a - Contributor Version directly or indirectly infringes any patent, then the rights granted to You - by any and all Contributors for the Covered Software under Section 2.1 of this License - shall terminate.

-

5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license - agreements (excluding distributors and resellers) which have been validly granted by You or Your - distributors under this License prior to termination shall survive termination.

-

6. Disclaimer of Warranty

-

Covered Software is provided under this License on an “as is” basis, without warranty of any - kind, either expressed, implied, or statutory, including, without limitation, warranties that - the Covered Software is free of defects, merchantable, fit for a particular purpose or - non-infringing. The entire risk as to the quality and performance of the Covered Software is - with You. Should any Covered Software prove defective in any respect, You (not any Contributor) - assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty - constitutes an essential part of this License. No use of any Covered Software is authorized - under this License except under this disclaimer.

-

7. Limitation of Liability

-

Under no circumstances and under no legal theory, whether tort (including negligence), - contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as - permitted above, be liable to You for any direct, indirect, special, incidental, or - consequential damages of any character including, without limitation, damages for lost profits, - loss of goodwill, work stoppage, computer failure or malfunction, or any and all other - commercial damages or losses, even if such party shall have been informed of the possibility of - such damages. This limitation of liability shall not apply to liability for death or personal - injury resulting from such party’s negligence to the extent applicable law prohibits such - limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or - consequential damages, so this exclusion and limitation may not apply to You.

-

8. Litigation

-

Any litigation relating to this License may be brought only in the courts of a jurisdiction where - the defendant maintains its principal place of business and such litigation shall be governed by - laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this - Section shall prevent a party’s ability to bring cross-claims or counter-claims.

-

9. Miscellaneous

-

This License represents the complete agreement concerning the subject matter hereof. If any - provision of this License is held to be unenforceable, such provision shall be reformed only to - the extent necessary to make it enforceable. Any law or regulation which provides that the - language of a contract shall be construed against the drafter shall not be used to construe this - License against a Contributor.

-

10. Versions of the License

-

10.1. New Versions

-

Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other - than the license steward has the right to modify or publish new versions of this License. Each - version will be given a distinguishing version number.

-

10.2. Effect of New Versions

-

You may distribute the Covered Software under the terms of the version of the License under which - You originally received the Covered Software, or under the terms of any subsequent version - published by the license steward.

-

10.3. Modified Versions

-

If you create software not governed by this License, and you want to create a new license for - such software, you may create and use a modified version of this License if you rename the - license and remove any references to the name of the license steward (except to note that such - modified license differs from this License).

-

10.4. - Distributing Source Code Form that is Incompatible With Secondary Licenses

-

If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under - the terms of this version of the License, the notice described in Exhibit B of this License must - be attached.

-

Exhibit A - Source Code Form License - Notice

-
-

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a - copy of the MPL was not distributed with this file, You can obtain one at - https://mozilla.org/MPL/2.0/.

-
-

If it is not possible or desirable to put the notice in a particular file, then You may include - the notice in a location (such as a LICENSE file in a relevant directory) where a recipient - would be likely to look for such a notice.

-

You may add additional accurate notices of copyright ownership.

-

Exhibit B - “Incompatible With - Secondary Licenses” Notice

-
-

This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla - Public License, v. 2.0.

-
- - - \ No newline at end of file diff --git a/app/src/main/assets/po_token.html b/app/src/main/assets/po_token.html deleted file mode 100644 index b55c13261..000000000 --- a/app/src/main/assets/po_token.html +++ /dev/null @@ -1,127 +0,0 @@ - - diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java deleted file mode 100644 index 8d03a1486..000000000 --- a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java +++ /dev/null @@ -1,344 +0,0 @@ -/* - * Copyright 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.fragment.app; - -import android.os.Bundle; -import android.os.Parcelable; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.os.BundleCompat; -import androidx.lifecycle.Lifecycle; -import androidx.viewpager.widget.PagerAdapter; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.ArrayList; - -// TODO: Replace this deprecated class with its ViewPager2 counterpart - -/** - * This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}. - *

- * It includes a workaround to fix the menu visibility when the adapter is restored. - *

- *

- * When restoring the state of this adapter, all the fragments' menu visibility were set to false, - * effectively disabling the menu from the user until he switched pages or another event - * that triggered the menu to be visible again happened. - *

- *

- * Check out the changes in: - *

- *
    - *
  • {@link #saveState()}
  • - *
  • {@link #restoreState(Parcelable, ClassLoader)}
  • - *
- * - * @deprecated Switch to {@link androidx.viewpager2.widget.ViewPager2} and use - * {@link androidx.viewpager2.adapter.FragmentStateAdapter} instead. - */ -@SuppressWarnings("deprecation") -@Deprecated -public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter { - private static final String TAG = "FragmentStatePagerAdapt"; - private static final boolean DEBUG = false; - - @Retention(RetentionPolicy.SOURCE) - @IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}) - private @interface Behavior { } - - /** - * Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current - * fragment changes. - * - * @deprecated This behavior relies on the deprecated - * {@link Fragment#setUserVisibleHint(boolean)} API. Use - * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement, - * {@link FragmentTransaction#setMaxLifecycle}. - * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) - */ - @Deprecated - public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0; - - /** - * Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED} - * state. All other Fragments are capped at {@link Lifecycle.State#STARTED}. - * - * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) - */ - public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1; - - private final FragmentManager mFragmentManager; - private final int mBehavior; - private FragmentTransaction mCurTransaction = null; - - private final ArrayList mSavedState = new ArrayList<>(); - private final ArrayList mFragments = new ArrayList<>(); - private Fragment mCurrentPrimaryItem = null; - private boolean mExecutingFinishUpdate; - - /** - * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround} - * that sets the fragment manager for the adapter. This is the equivalent of calling - * {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in - * {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}. - * - *

Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the - * current Fragment changes.

- * - * @param fm fragment manager that will interact with this adapter - * @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with - * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} - */ - @Deprecated - public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm) { - this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); - } - - /** - * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}. - * - * If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current - * Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are - * capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is - * passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be - * callbacks to {@link Fragment#setUserVisibleHint(boolean)}. - * - * @param fm fragment manager that will interact with this adapter - * @param behavior determines if only current fragments are in a resumed state - */ - public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm, - @Behavior final int behavior) { - mFragmentManager = fm; - mBehavior = behavior; - } - - /** - * @param position the position of the item you want - * @return the {@link Fragment} associated with a specified position - */ - @NonNull - public abstract Fragment getItem(int position); - - @Override - public void startUpdate(@NonNull final ViewGroup container) { - if (container.getId() == View.NO_ID) { - throw new IllegalStateException("ViewPager with adapter " + this - + " requires a view id"); - } - } - - @SuppressWarnings("deprecation") - @NonNull - @Override - public Object instantiateItem(@NonNull final ViewGroup container, final int position) { - // If we already have this item instantiated, there is nothing - // to do. This can happen when we are restoring the entire pager - // from its saved state, where the fragment manager has already - // taken care of restoring the fragments we previously had instantiated. - if (mFragments.size() > position) { - final Fragment f = mFragments.get(position); - if (f != null) { - return f; - } - } - - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction(); - } - - final Fragment fragment = getItem(position); - if (DEBUG) { - Log.v(TAG, "Adding item #" + position + ": f=" + fragment); - } - if (mSavedState.size() > position) { - final Fragment.SavedState fss = mSavedState.get(position); - if (fss != null) { - fragment.setInitialSavedState(fss); - } - } - while (mFragments.size() <= position) { - mFragments.add(null); - } - fragment.setMenuVisibility(false); - if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) { - fragment.setUserVisibleHint(false); - } - - mFragments.set(position, fragment); - mCurTransaction.add(container.getId(), fragment); - - if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED); - } - - return fragment; - } - - @Override - public void destroyItem(@NonNull final ViewGroup container, final int position, - @NonNull final Object object) { - final Fragment fragment = (Fragment) object; - - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction(); - } - if (DEBUG) { - Log.v(TAG, "Removing item #" + position + ": f=" + object - + " v=" + ((Fragment) object).getView()); - } - while (mSavedState.size() <= position) { - mSavedState.add(null); - } - mSavedState.set(position, fragment.isAdded() - ? mFragmentManager.saveFragmentInstanceState(fragment) : null); - mFragments.set(position, null); - - mCurTransaction.remove(fragment); - if (fragment.equals(mCurrentPrimaryItem)) { - mCurrentPrimaryItem = null; - } - } - - @Override - @SuppressWarnings({"ReferenceEquality", "deprecation"}) - public void setPrimaryItem(@NonNull final ViewGroup container, final int position, - @NonNull final Object object) { - final Fragment fragment = (Fragment) object; - if (fragment != mCurrentPrimaryItem) { - if (mCurrentPrimaryItem != null) { - mCurrentPrimaryItem.setMenuVisibility(false); - if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction(); - } - mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED); - } else { - mCurrentPrimaryItem.setUserVisibleHint(false); - } - } - fragment.setMenuVisibility(true); - if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction(); - } - mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED); - } else { - fragment.setUserVisibleHint(true); - } - - mCurrentPrimaryItem = fragment; - } - } - - @Override - public void finishUpdate(@NonNull final ViewGroup container) { - if (mCurTransaction != null) { - // We drop any transactions that attempt to be committed - // from a re-entrant call to finishUpdate(). We need to - // do this as a workaround for Robolectric running measure/layout - // calls inline rather than allowing them to be posted - // as they would on a real device. - if (!mExecutingFinishUpdate) { - try { - mExecutingFinishUpdate = true; - mCurTransaction.commitNowAllowingStateLoss(); - } finally { - mExecutingFinishUpdate = false; - } - } - mCurTransaction = null; - } - } - - @Override - public boolean isViewFromObject(@NonNull final View view, @NonNull final Object object) { - return ((Fragment) object).getView() == view; - } - - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - private final String selectedFragment = "selected_fragment"; - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - - @Override - @Nullable - public Parcelable saveState() { - Bundle state = null; - if (!mSavedState.isEmpty()) { - state = new Bundle(); - state.putParcelableArrayList("states", mSavedState); - } - for (int i = 0; i < mFragments.size(); i++) { - final Fragment f = mFragments.get(i); - if (f != null && f.isAdded()) { - if (state == null) { - state = new Bundle(); - } - final String key = "f" + i; - mFragmentManager.putFragment(state, key, f); - - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // Check if it's the same fragment instance - if (f == mCurrentPrimaryItem) { - state.putString(selectedFragment, key); - } - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - } - } - return state; - } - - @Override - public void restoreState(@Nullable final Parcelable state, @Nullable final ClassLoader loader) { - if (state != null) { - final Bundle bundle = (Bundle) state; - bundle.setClassLoader(loader); - final var states = BundleCompat.getParcelableArrayList(bundle, "states", - Fragment.SavedState.class); - mSavedState.clear(); - mFragments.clear(); - if (states != null) { - mSavedState.addAll(states); - } - final Iterable keys = bundle.keySet(); - for (final String key : keys) { - if (key.startsWith("f")) { - final int index = Integer.parseInt(key.substring(1)); - final Fragment f = mFragmentManager.getFragment(bundle, key); - if (f != null) { - while (mFragments.size() <= index) { - mFragments.add(null); - } - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - final boolean wasSelected = bundle.getString(selectedFragment, "") - .equals(key); - f.setMenuVisibility(wasSelected); - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - mFragments.set(index, f); - } else { - Log.w(TAG, "Bad fragment at key " + key); - } - } - } - } - } -} diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java deleted file mode 100644 index 52754e8fa..000000000 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.google.android.material.appbar; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.OverScroller; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.coordinatorlayout.widget.CoordinatorLayout; - -import org.schabi.newpipe.R; - -import java.lang.reflect.Field; -import java.util.List; - -// See https://stackoverflow.com/questions/56849221#57997489 -public final class FlingBehavior extends AppBarLayout.Behavior { - private final Rect focusScrollRect = new Rect(); - - public FlingBehavior(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - private boolean allowScroll = true; - private final Rect globalRect = new Rect(); - private final List skipInterceptionOfElements = List.of( - R.id.itemsListPanel, R.id.playbackSeekBar, - R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); - - @Override - public boolean onRequestChildRectangleOnScreen( - @NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child, - @NonNull final Rect rectangle, final boolean immediate) { - focusScrollRect.set(rectangle); - - coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect); - - final int height = coordinatorLayout.getHeight(); - - if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) { - // the child is too big to fit inside ourselves completely, ignore request - return false; - } - - final int dy; - - if (focusScrollRect.bottom > height) { - dy = focusScrollRect.top; - } else if (focusScrollRect.top < 0) { - // scrolling up - dy = -(height - focusScrollRect.bottom); - } else { - // nothing to do - return false; - } - - final int consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0); - - return consumed == dy; - } - - @Override - public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, - @NonNull final AppBarLayout child, - @NonNull final MotionEvent ev) { - for (final int element : skipInterceptionOfElements) { - final View view = child.findViewById(element); - if (view != null) { - final boolean visible = view.getGlobalVisibleRect(globalRect); - if (visible && globalRect.contains((int) ev.getRawX(), (int) ev.getRawY())) { - allowScroll = false; - return false; - } - } - } - allowScroll = true; - switch (ev.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - // remove reference to old nested scrolling child - resetNestedScrollingChild(); - // Stop fling when your finger touches the screen - stopAppBarLayoutFling(); - break; - default: - break; - } - return super.onInterceptTouchEvent(parent, child, ev); - } - - @Override - public boolean onStartNestedScroll(@NonNull final CoordinatorLayout parent, - @NonNull final AppBarLayout child, - @NonNull final View directTargetChild, - final View target, - final int nestedScrollAxes, - final int type) { - return allowScroll && super.onStartNestedScroll( - parent, child, directTargetChild, target, nestedScrollAxes, type); - } - - @Override - public boolean onNestedFling(@NonNull final CoordinatorLayout coordinatorLayout, - @NonNull final AppBarLayout child, - @NonNull final View target, final float velocityX, - final float velocityY, final boolean consumed) { - return allowScroll && super.onNestedFling( - coordinatorLayout, child, target, velocityX, velocityY, consumed); - } - - @Nullable - private OverScroller getScrollerField() { - try { - final Class headerBehaviorType = this.getClass() - .getSuperclass().getSuperclass().getSuperclass(); - if (headerBehaviorType != null) { - final Field field = headerBehaviorType.getDeclaredField("scroller"); - field.setAccessible(true); - return ((OverScroller) field.get(this)); - } - } catch (final NoSuchFieldException | IllegalAccessException e) { - // ? - } - return null; - } - - @Nullable - private Field getLastNestedScrollingChildRefField() { - try { - final Class headerBehaviorType = this.getClass().getSuperclass().getSuperclass(); - if (headerBehaviorType != null) { - final Field field = - headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef"); - field.setAccessible(true); - return field; - } - } catch (final NoSuchFieldException e) { - // ? - } - return null; - } - - private void resetNestedScrollingChild() { - final Field field = getLastNestedScrollingChildRefField(); - if (field != null) { - try { - final Object value = field.get(this); - if (value != null) { - field.set(this, null); - } - } catch (final IllegalAccessException e) { - // ? - } - } - } - - private void stopAppBarLayoutFling() { - final OverScroller scroller = getScrollerField(); - if (scroller != null) { - scroller.forceFinished(true); - } - } -} diff --git a/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java b/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java deleted file mode 100644 index bbab7fd78..000000000 --- a/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.commons.text.similarity; - -import java.util.Locale; - -/** - * A matching algorithm that is similar to the searching algorithms implemented in editors such - * as Sublime Text, TextMate, Atom and others. - * - *

- * One point is given for every matched character. Subsequent matches yield two bonus points. - * A higher score indicates a higher similarity. - *

- * - *

- * This code has been adapted from Apache Commons Lang 3.3. - *

- * - * @since 1.0 - * - * Note: This class was forked from - * - * apache/commons-text (8cfdafc) FuzzyScore.java - * - */ -public class FuzzyScore { - - /** - * Locale used to change the case of text. - */ - private final Locale locale; - - - /** - * This returns a {@link Locale}-specific {@link FuzzyScore}. - * - * @param locale The string matching logic is case insensitive. - A {@link Locale} is necessary to normalize both Strings to lower case. - * @throws IllegalArgumentException - * This is thrown if the {@link Locale} parameter is {@code null}. - */ - public FuzzyScore(final Locale locale) { - if (locale == null) { - throw new IllegalArgumentException("Locale must not be null"); - } - this.locale = locale; - } - - /** - * Find the Fuzzy Score which indicates the similarity score between two - * Strings. - * - *
-     * score.fuzzyScore(null, null)                          = IllegalArgumentException
-     * score.fuzzyScore("not null", null)                    = IllegalArgumentException
-     * score.fuzzyScore(null, "not null")                    = IllegalArgumentException
-     * score.fuzzyScore("", "")                              = 0
-     * score.fuzzyScore("Workshop", "b")                     = 0
-     * score.fuzzyScore("Room", "o")                         = 1
-     * score.fuzzyScore("Workshop", "w")                     = 1
-     * score.fuzzyScore("Workshop", "ws")                    = 2
-     * score.fuzzyScore("Workshop", "wo")                    = 4
-     * score.fuzzyScore("Apache Software Foundation", "asf") = 3
-     * 
- * - * @param term a full term that should be matched against, must not be null - * @param query the query that will be matched against a term, must not be - * null - * @return result score - * @throws IllegalArgumentException if the term or query is {@code null} - */ - public Integer fuzzyScore(final CharSequence term, final CharSequence query) { - if (term == null || query == null) { - throw new IllegalArgumentException("CharSequences must not be null"); - } - - // fuzzy logic is case insensitive. We normalize the Strings to lower - // case right from the start. Turning characters to lower case - // via Character.toLowerCase(char) is unfortunately insufficient - // as it does not accept a locale. - final String termLowerCase = term.toString().toLowerCase(locale); - final String queryLowerCase = query.toString().toLowerCase(locale); - - // the resulting score - int score = 0; - - // the position in the term which will be scanned next for potential - // query character matches - int termIndex = 0; - - // index of the previously matched character in the term - int previousMatchingCharacterIndex = Integer.MIN_VALUE; - - for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) { - final char queryChar = queryLowerCase.charAt(queryIndex); - - boolean termCharacterMatchFound = false; - for (; termIndex < termLowerCase.length() - && !termCharacterMatchFound; termIndex++) { - final char termChar = termLowerCase.charAt(termIndex); - - if (queryChar == termChar) { - // simple character matches result in one point - score++; - - // subsequent character matches further improve - // the score. - if (previousMatchingCharacterIndex + 1 == termIndex) { - score += 2; - } - - previousMatchingCharacterIndex = termIndex; - - // we can leave the nested loop. Every character in the - // query can match at most one character in the term. - termCharacterMatchFound = true; - } - } - } - - return score; - } - - /** - * Gets the locale. - * - * @return The locale - */ - public Locale getLocale() { - return locale; - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/App.kt b/app/src/main/java/org/schabi/newpipe/App.kt deleted file mode 100644 index 3ca259528..000000000 --- a/app/src/main/java/org/schabi/newpipe/App.kt +++ /dev/null @@ -1,293 +0,0 @@ -package org.schabi.newpipe - -import android.app.ActivityManager -import android.app.Application -import android.content.Context -import android.util.Log -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.getSystemService -import androidx.preference.PreferenceManager -import coil3.ImageLoader -import coil3.SingletonImageLoader -import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import coil3.request.allowRgb565 -import coil3.request.crossfade -import coil3.util.DebugLogger -import com.jakewharton.processphoenix.ProcessPhoenix -import io.reactivex.rxjava3.exceptions.CompositeException -import io.reactivex.rxjava3.exceptions.MissingBackpressureException -import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException -import io.reactivex.rxjava3.exceptions.UndeliverableException -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.plugins.RxJavaPlugins -import java.io.IOException -import java.io.InterruptedIOException -import java.net.SocketException -import org.acra.ACRA.init -import org.acra.ACRA.isACRASenderServiceProcess -import org.acra.config.CoreConfigurationBuilder -import org.schabi.newpipe.error.ReCaptchaActivity -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.downloader.Downloader -import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor -import org.schabi.newpipe.ktx.hasAssignableCause -import org.schabi.newpipe.settings.NewPipeSettings -import org.schabi.newpipe.util.BridgeStateSaverInitializer -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ServiceHelper -import org.schabi.newpipe.util.StateSaver -import org.schabi.newpipe.util.image.ImageStrategy -import org.schabi.newpipe.util.image.PreferredImageQuality -import org.schabi.newpipe.util.potoken.PoTokenProviderImpl - -/* - * Copyright (C) Hans-Christoph Steiner 2016 - * App.kt is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -open class App : - Application(), - SingletonImageLoader.Factory { - var isFirstRun = false - private set - var notificationsRequested = false - private set - - fun setNotificationsRequested() { - notificationsRequested = true - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - initACRA() - } - - override fun onCreate() { - super.onCreate() - - instance = this - - if (ProcessPhoenix.isPhoenixProcess(this)) { - Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]") - return - } - - // check if the last used preference version is set - // to determine whether this is the first app run - val lastUsedPrefVersion = - PreferenceManager - .getDefaultSharedPreferences(this) - .getInt(getString(R.string.last_used_preferences_version), -1) - isFirstRun = lastUsedPrefVersion == -1 - - // Initialize settings first because other initializations can use its values - NewPipeSettings.initSettings(this) - - NewPipe.init( - getDownloader(), - Localization.getPreferredLocalization(this), - Localization.getPreferredContentCountry(this) - ) - Localization.initPrettyTime(Localization.resolvePrettyTime()) - - BridgeStateSaverInitializer.init(this) - StateSaver.init(this) - initNotificationChannels() - - ServiceHelper.initServices(this) - - // Initialize image loader - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - ImageStrategy.setPreferredImageQuality( - PreferredImageQuality.fromPreferenceKey( - this, - prefs.getString( - getString(R.string.image_quality_key), - getString(R.string.image_quality_default) - ) - ) - ) - - configureRxJavaErrorHandler() - - YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl) - } - - override fun newImageLoader(context: Context): ImageLoader = ImageLoader - .Builder(this) - .logger(if (BuildConfig.DEBUG) DebugLogger() else null) - .allowRgb565(getSystemService()!!.isLowRamDevice) - .crossfade(true) - .components { - add(OkHttpNetworkFetcherFactory(callFactory = DownloaderImpl.getInstance().client)) - }.build() - - protected open fun getDownloader(): Downloader { - val downloader = DownloaderImpl.init(null) - setCookiesToDownloader(downloader) - return downloader - } - - protected fun setCookiesToDownloader(downloader: DownloaderImpl) { - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - val key = getString(R.string.recaptcha_cookies_key) - downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null)) - downloader.updateYoutubeRestrictedModeCookies(this) - } - - private fun configureRxJavaErrorHandler() { - // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling - RxJavaPlugins.setErrorHandler( - object : Consumer { - override fun accept(throwable: Throwable) { - Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]") - - // As UndeliverableException is a wrapper, - // get the cause of it to get the "real" exception - val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable - - val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable) - - for (error in errors) { - if (isThrowableIgnored(error)) { - return - } - if (isThrowableCritical(error)) { - reportException(error) - return - } - } - - // Out-of-lifecycle exceptions should only be reported if a debug user wishes so, - // When exception is not reported, log it - if (isDisposedRxExceptionsReported()) { - reportException(actualThrowable) - } else { - Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable) - } - } - - fun isThrowableIgnored(throwable: Throwable): Boolean { - // Don't crash the application over a simple network problem - return throwable // network api cancellation - .hasAssignableCause( - IOException::class.java, - SocketException::class.java, // blocking code disposed - InterruptedException::class.java, - InterruptedIOException::class.java - ) - } - - fun isThrowableCritical(throwable: Throwable): Boolean { - // Though these exceptions cannot be ignored - return throwable - .hasAssignableCause( - // bug in app - NullPointerException::class.java, - IllegalArgumentException::class.java, - OnErrorNotImplementedException::class.java, - MissingBackpressureException::class.java, - // bug in operator - IllegalStateException::class.java - ) - } - - fun reportException(throwable: Throwable) { - // Throw uncaught exception that will trigger the report system - Thread - .currentThread() - .uncaughtExceptionHandler - .uncaughtException(Thread.currentThread(), throwable) - } - } - ) - } - - /** - * Called in [.attachBaseContext] after calling the `super` method. - * Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA. - */ - protected fun initACRA() { - if (isACRASenderServiceProcess()) { - return - } - - val acraConfig = - CoreConfigurationBuilder() - .withBuildConfigClass(BuildConfig::class.java) - init(this, acraConfig) - } - - private fun initNotificationChannels() { - // Keep the importance below DEFAULT to avoid making noise on every notification update for - // the main and update channels - val mainChannel = - NotificationChannelCompat - .Builder( - getString(R.string.notification_channel_id), - NotificationManagerCompat.IMPORTANCE_LOW - ).setName(getString(R.string.notification_channel_name)) - .setDescription(getString(R.string.notification_channel_description)) - .build() - val appUpdateChannel = - NotificationChannelCompat - .Builder( - getString(R.string.app_update_notification_channel_id), - NotificationManagerCompat.IMPORTANCE_LOW - ).setName(getString(R.string.app_update_notification_channel_name)) - .setDescription(getString(R.string.app_update_notification_channel_description)) - .build() - val hashChannel = - NotificationChannelCompat - .Builder( - getString(R.string.hash_channel_id), - NotificationManagerCompat.IMPORTANCE_HIGH - ).setName(getString(R.string.hash_channel_name)) - .setDescription(getString(R.string.hash_channel_description)) - .build() - val errorReportChannel = - NotificationChannelCompat - .Builder( - getString(R.string.error_report_channel_id), - NotificationManagerCompat.IMPORTANCE_LOW - ).setName(getString(R.string.error_report_channel_name)) - .setDescription(getString(R.string.error_report_channel_description)) - .build() - val newStreamChannel = - NotificationChannelCompat - .Builder( - getString(R.string.streams_notification_channel_id), - NotificationManagerCompat.IMPORTANCE_DEFAULT - ).setName(getString(R.string.streams_notification_channel_name)) - .setDescription(getString(R.string.streams_notification_channel_description)) - .build() - - val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel) - - NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels) - } - - protected open fun isDisposedRxExceptionsReported(): Boolean = false - - companion object { - const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID - private val TAG = App::class.java.toString() - - @JvmStatic - lateinit var instance: App - private set - } -} diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java deleted file mode 100644 index a55a341e6..000000000 --- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java +++ /dev/null @@ -1,140 +0,0 @@ -package org.schabi.newpipe; - -import android.content.Context; -import android.os.Bundle; -import android.util.Log; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; - -import com.evernote.android.state.State; -import com.livefront.bridge.Bridge; - - -public abstract class BaseFragment extends Fragment { - protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); - protected static final boolean DEBUG = MainActivity.DEBUG; - protected AppCompatActivity activity; - //These values are used for controlling fragments when they are part of the frontpage - @State - protected boolean useAsFrontPage = false; - - public void useAsFrontPage(final boolean value) { - useAsFrontPage = value; - } - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - activity = (AppCompatActivity) context; - } - - @Override - public void onDetach() { - super.onDetach(); - activity = null; - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - if (DEBUG) { - Log.d(TAG, "onCreate() called with: " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - super.onCreate(savedInstanceState); - Bridge.restoreInstanceState(this, savedInstanceState); - if (savedInstanceState != null) { - onRestoreInstanceState(savedInstanceState); - } - } - - - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - if (DEBUG) { - Log.d(TAG, "onViewCreated() called with: " - + "rootView = [" + rootView + "], " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - initViews(rootView, savedInstanceState); - initListeners(); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } - - protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - /** - * This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views. - * - *

- * {@link #initListeners()} is called after this method to initialize the corresponding - * listeners. - *

- * @param rootView The inflated view for this fragment - * (provided by {@link #onViewCreated(View, Bundle)}) - * @param savedInstanceState The saved state of this fragment - * (provided by {@link #onViewCreated(View, Bundle)}) - */ - protected void initViews(final View rootView, final Bundle savedInstanceState) { - } - - /** - * Initialize the listeners for this fragment. - * - *

- * This method is called after {@link #initViews(View, Bundle)} - * in {@link #onViewCreated(View, Bundle)}. - *

- */ - protected void initListeners() { - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - public void setTitle(final String title) { - if (DEBUG) { - Log.d(TAG, "setTitle() called with: title = [" + title + "]"); - } - if (!useAsFrontPage && activity != null && activity.getSupportActionBar() != null) { - activity.getSupportActionBar().setDisplayShowTitleEnabled(true); - activity.getSupportActionBar().setTitle(title); - } - } - - /** - * Finds the root fragment by looping through all of the parent fragments. The root fragment - * is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that - * handles keeping the backstack of opened fragments in NewPipe, and also the player bottom - * sheet. This function therefore returns the fragment manager of said fragment. - * - * @return the fragment manager of the root fragment, i.e. - * {@link org.schabi.newpipe.fragments.MainFragment} - */ - protected FragmentManager getFM() { - Fragment current = this; - while (current.getParentFragment() != null) { - current = current.getParentFragment(); - } - return current.getFragmentManager(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java deleted file mode 100644 index 74a2cab51..000000000 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ /dev/null @@ -1,181 +0,0 @@ -package org.schabi.newpipe; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.extractor.downloader.Downloader; -import org.schabi.newpipe.extractor.downloader.Request; -import org.schabi.newpipe.extractor.downloader.Response; -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; -import org.schabi.newpipe.util.InfoCache; - -import java.io.IOException; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; - -public final class DownloaderImpl extends Downloader { - public static final String USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0"; - public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = - "youtube_restricted_mode_key"; - public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; - public static final String YOUTUBE_DOMAIN = "youtube.com"; - - private static DownloaderImpl instance; - private final Map mCookies; - private final OkHttpClient client; - - private DownloaderImpl(final OkHttpClient.Builder builder) { - this.client = builder - .readTimeout(30, TimeUnit.SECONDS) -// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), -// 16 * 1024 * 1024)) - .build(); - this.mCookies = new HashMap<>(); - } - - @NonNull - public OkHttpClient getClient() { - return client; - } - - /** - * It's recommended to call exactly once in the entire lifetime of the application. - * - * @param builder if null, default builder will be used - * @return a new instance of {@link DownloaderImpl} - */ - public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { - instance = new DownloaderImpl( - builder != null ? builder : new OkHttpClient.Builder()); - return instance; - } - - public static DownloaderImpl getInstance() { - return instance; - } - - public String getCookies(final String url) { - final String youtubeCookie = url.contains(YOUTUBE_DOMAIN) - ? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null; - - // Recaptcha cookie is always added TODO: not sure if this is necessary - return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY)) - .filter(Objects::nonNull) - .flatMap(cookies -> Arrays.stream(cookies.split("; *"))) - .distinct() - .collect(Collectors.joining("; ")); - } - - public String getCookie(final String key) { - return mCookies.get(key); - } - - public void setCookie(final String key, final String cookie) { - mCookies.put(key, cookie); - } - - public void removeCookie(final String key) { - mCookies.remove(key); - } - - public void updateYoutubeRestrictedModeCookies(final Context context) { - final String restrictedModeEnabledKey = - context.getString(R.string.youtube_restricted_mode_enabled); - final boolean restrictedModeEnabled = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(restrictedModeEnabledKey, false); - updateYoutubeRestrictedModeCookies(restrictedModeEnabled); - } - - public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedModeEnabled) { - if (youtubeRestrictedModeEnabled) { - setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY, - YOUTUBE_RESTRICTED_MODE_COOKIE); - } else { - removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); - } - InfoCache.getInstance().clearCache(); - } - - /** - * Get the size of the content that the url is pointing by firing a HEAD request. - * - * @param url an url pointing to the content - * @return the size of the content, in bytes - */ - public long getContentLength(final String url) throws IOException { - try { - final Response response = head(url); - return Long.parseLong(response.getHeader("Content-Length")); - } catch (final NumberFormatException e) { - throw new IOException("Invalid content length", e); - } catch (final ReCaptchaException e) { - throw new IOException(e); - } - } - - @Override - public Response execute(@NonNull final Request request) - throws IOException, ReCaptchaException { - final String httpMethod = request.httpMethod(); - final String url = request.url(); - final Map> headers = request.headers(); - final byte[] dataToSend = request.dataToSend(); - - RequestBody requestBody = null; - if (dataToSend != null) { - requestBody = RequestBody.create(dataToSend); - } - - final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() - .method(httpMethod, requestBody) - .url(url) - .addHeader("User-Agent", USER_AGENT); - - final String cookies = getCookies(url); - if (!cookies.isEmpty()) { - requestBuilder.addHeader("Cookie", cookies); - } - - headers.forEach((headerName, headerValueList) -> { - requestBuilder.removeHeader(headerName); - headerValueList.forEach(headerValue -> - requestBuilder.addHeader(headerName, headerValue)); - }); - - try ( - okhttp3.Response response = client.newCall(requestBuilder.build()).execute() - ) { - if (response.code() == 429) { - throw new ReCaptchaException("reCaptcha Challenge requested", url); - } - - String responseBodyToReturn = null; - try (ResponseBody body = response.body()) { - responseBodyToReturn = body.string(); - } - - final String latestUrl = response.request().url().toString(); - return new Response( - response.code(), - response.message(), - response.headers().toMultimap(), - responseBodyToReturn, - latestUrl); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/ExitActivity.kt b/app/src/main/java/org/schabi/newpipe/ExitActivity.kt deleted file mode 100644 index cc9f448b7..000000000 --- a/app/src/main/java/org/schabi/newpipe/ExitActivity.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2016-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import org.schabi.newpipe.util.NavigationHelper - -class ExitActivity : Activity() { - @SuppressLint("NewApi") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - finishAndRemoveTask() - NavigationHelper.restartApp(this) - } - - companion object { - @JvmStatic - fun exitAndRemoveFromRecentApps(activity: Activity) { - val intent = Intent(activity, ExitActivity::class.java) - intent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_NO_ANIMATION - ) - - activity.startActivity(intent) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java deleted file mode 100644 index 6aa87b4bd..000000000 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ /dev/null @@ -1,1061 +0,0 @@ -/* - * Created by Christian Schabesberger on 02.08.16. - *

- * Copyright (C) Christian Schabesberger 2016 - * DownloadActivity.java is part of NewPipe. - *

- * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -package org.schabi.newpipe; - -import android.app.AlertDialog; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.FrameLayout; -import android.widget.Spinner; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.ActionBarDrawerToggle; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentContainerView; -import androidx.fragment.app.FragmentManager; -import androidx.preference.PreferenceManager; - -import com.google.android.material.bottomsheet.BottomSheetBehavior; - -import org.schabi.newpipe.databinding.ActivityMainBinding; -import org.schabi.newpipe.databinding.DrawerHeaderBinding; -import org.schabi.newpipe.databinding.DrawerLayoutBinding; -import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding; -import org.schabi.newpipe.databinding.ToolbarLayoutBinding; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; -import org.schabi.newpipe.fragments.BackPressable; -import org.schabi.newpipe.fragments.MainFragment; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; -import org.schabi.newpipe.fragments.list.search.SearchFragment; -import org.schabi.newpipe.local.feed.notifications.NotificationWorker; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.event.OnKeyDownListener; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.settings.UpdateSettingsFragment; -import org.schabi.newpipe.settings.migration.MigrationManager; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.KioskTranslator; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PeertubeHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ReleaseVersionUtil; -import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.views.FocusOverlayView; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public class MainActivity extends AppCompatActivity { - private static final String TAG = "MainActivity"; - @SuppressWarnings("ConstantConditions") - public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); - - private ActivityMainBinding mainBinding; - private DrawerHeaderBinding drawerHeaderBinding; - private DrawerLayoutBinding drawerLayoutBinding; - private ToolbarLayoutBinding toolbarLayoutBinding; - - private ActionBarDrawerToggle toggle; - - private boolean servicesShown = false; - - private BroadcastReceiver broadcastReceiver; - - private static final int ITEM_ID_SUBSCRIPTIONS = -1; - private static final int ITEM_ID_FEED = -2; - private static final int ITEM_ID_BOOKMARKS = -3; - private static final int ITEM_ID_DOWNLOADS = -4; - private static final int ITEM_ID_HISTORY = -5; - private static final int ITEM_ID_SETTINGS = 0; - private static final int ITEM_ID_DONATION = 1; - private static final int ITEM_ID_ABOUT = 2; - - private static final int ORDER = 0; - public static final String KEY_IS_IN_BACKGROUND = "is_in_background"; - - private SharedPreferences sharedPreferences; - private SharedPreferences.Editor sharedPrefEditor; - /*////////////////////////////////////////////////////////////////////////// - // Activity's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void onCreate(final Bundle savedInstanceState) { - if (DEBUG) { - Log.d(TAG, "onCreate() called with: " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - - Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext()); - ThemeHelper.setDayNightMode(this); - ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); - - // Fixes text color turning black in dark/black mode: - // https://github.com/TeamNewPipe/NewPipe/issues/12016 - // For further reference see: https://issuetracker.google.com/issues/37124582 - if (DeviceUtils.supportsWebView()) { - try { - new WebView(this); - } catch (final Throwable e) { - if (DEBUG) { - Log.e(TAG, "Failed to create WebView", e); - } - } - } - - super.onCreate(savedInstanceState); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - sharedPrefEditor = sharedPreferences.edit(); - - mainBinding = ActivityMainBinding.inflate(getLayoutInflater()); - drawerLayoutBinding = mainBinding.drawerLayout; - drawerHeaderBinding = DrawerHeaderBinding.bind(drawerLayoutBinding.navigation - .getHeaderView(0)); - toolbarLayoutBinding = mainBinding.toolbarLayout; - setContentView(mainBinding.getRoot()); - - if (getSupportFragmentManager().getBackStackEntryCount() == 0) { - initFragments(); - } - - setSupportActionBar(toolbarLayoutBinding.toolbar); - try { - setupDrawer(); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e); - } - if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(this); - } - openMiniPlayerUponPlayerStarted(); - - if (PermissionHelper.checkPostNotificationsPermission(this, - PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) { - // Schedule worker for checking for new streams and creating corresponding notifications - // if this is enabled by the user. - NotificationWorker.initialize(this); - } - if (!UpdateSettingsFragment.wasUserAskedForConsent(this) - && !App.getInstance().isFirstRun() - && ReleaseVersionUtil.INSTANCE.isReleaseApk()) { - UpdateSettingsFragment.askForConsentToUpdateChecks(this); - } - - // ReleaseVersionUtil.INSTANCE.isReleaseApk() will be true only for main official build - // We want every release build (nightly, nightly-refactor) to show the popup - if (!DEBUG) { - showKeepAndroidDialog(); - showApi23RequirementDialog(); - } - - MigrationManager.showUserInfoIfPresent(this); - } - - @Override - protected void onPostCreate(final Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - - final App app = App.getInstance(); - - if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false) - && sharedPreferences - .getBoolean(app.getString(R.string.update_check_consent_key), false)) { - // Start the worker which is checking all conditions - // and eventually searching for a new version. - NewVersionWorker.enqueueNewVersionCheckingWork(app, false); - } - } - - @Override - protected void onStart() { - super.onStart(); - sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply(); - Log.d(TAG, "App moved to foreground"); - } - - @Override - protected void onStop() { - super.onStop(); - sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply(); - Log.d(TAG, "App moved to background"); - } - private void setupDrawer() throws ExtractionException { - addDrawerMenuForCurrentService(); - - toggle = new ActionBarDrawerToggle(this, mainBinding.getRoot(), - toolbarLayoutBinding.toolbar, R.string.drawer_open, R.string.drawer_close); - toggle.syncState(); - mainBinding.getRoot().addDrawerListener(toggle); - mainBinding.getRoot().addDrawerListener(new DrawerLayout.SimpleDrawerListener() { - private int lastService; - - @Override - public void onDrawerOpened(final View drawerView) { - lastService = ServiceHelper.getSelectedServiceId(MainActivity.this); - } - - @Override - public void onDrawerClosed(final View drawerView) { - if (servicesShown) { - toggleServices(); - } - if (lastService != ServiceHelper.getSelectedServiceId(MainActivity.this)) { - ActivityCompat.recreate(MainActivity.this); - } - } - }); - - drawerLayoutBinding.navigation.setNavigationItemSelectedListener(this::drawerItemSelected); - setupDrawerHeader(); - } - - /** - * Builds the drawer menu for the current service. - * - * @throws ExtractionException if the service didn't provide available kiosks - */ - private void addDrawerMenuForCurrentService() throws ExtractionException { - //Tabs - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, - R.string.tab_subscriptions) - .setIcon(R.drawable.ic_tv); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) - .setIcon(R.drawable.ic_subscriptions); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) - .setIcon(R.drawable.ic_bookmark); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) - .setIcon(R.drawable.ic_file_download); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) - .setIcon(R.drawable.ic_history); - - //Kiosks - final int currentServiceId = ServiceHelper.getSelectedServiceId(this); - final StreamingService service = NewPipe.getService(currentServiceId); - - int kioskMenuItemId = 0; - - for (final String ks : service.getKioskList().getAvailableKiosks()) { - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator - .getTranslatedKioskName(ks, this)) - .setIcon(KioskTranslator.getKioskIcon(ks)); - kioskMenuItemId++; - } - - //Settings and About - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) - .setIcon(R.drawable.ic_settings); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER, - R.string.donation_title) - .setIcon(R.drawable.volunteer_activism_ic); - drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) - .setIcon(R.drawable.ic_info_outline); - } - - private boolean drawerItemSelected(final MenuItem item) { - final int groupId = item.getGroupId(); - if (groupId == R.id.menu_services_group) { - changeService(item); - } else if (groupId == R.id.menu_tabs_group) { - tabSelected(item); - } else if (groupId == R.id.menu_kiosks_group) { - try { - kioskSelected(item); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e); - } - } else if (groupId == R.id.menu_options_about_group) { - optionsAboutSelected(item); - } else { - return false; - } - - mainBinding.getRoot().closeDrawers(); - return true; - } - - private void changeService(final MenuItem item) { - drawerLayoutBinding.navigation.getMenu() - .getItem(ServiceHelper.getSelectedServiceId(this)) - .setChecked(false); - ServiceHelper.setSelectedServiceId(this, item.getItemId()); - drawerLayoutBinding.navigation.getMenu() - .getItem(ServiceHelper.getSelectedServiceId(this)) - .setChecked(true); - } - - private void tabSelected(final MenuItem item) { - switch (item.getItemId()) { - case ITEM_ID_SUBSCRIPTIONS: - NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()); - break; - case ITEM_ID_FEED: - NavigationHelper.openFeedFragment(getSupportFragmentManager()); - break; - case ITEM_ID_BOOKMARKS: - NavigationHelper.openBookmarksFragment(getSupportFragmentManager()); - break; - case ITEM_ID_DOWNLOADS: - NavigationHelper.openDownloads(this); - break; - case ITEM_ID_HISTORY: - NavigationHelper.openStatisticFragment(getSupportFragmentManager()); - break; - } - } - - private void kioskSelected(final MenuItem item) throws ExtractionException { - final StreamingService currentService = ServiceHelper.getSelectedService(this); - int kioskMenuItemId = 0; - for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) { - if (kioskMenuItemId == item.getItemId()) { - NavigationHelper.openKioskFragment(getSupportFragmentManager(), - currentService.getServiceId(), kioskId); - break; - } - kioskMenuItemId++; - } - } - - private void optionsAboutSelected(final MenuItem item) { - switch (item.getItemId()) { - case ITEM_ID_SETTINGS: - NavigationHelper.openSettings(this); - break; - case ITEM_ID_DONATION: - ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url)); - break; - case ITEM_ID_ABOUT: - NavigationHelper.openAbout(this); - break; - } - } - - private void setupDrawerHeader() { - drawerHeaderBinding.drawerHeaderActionButton.setOnClickListener(view -> toggleServices()); - - // If the current app name is bigger than the default "NewPipe" (7 chars), - // let the text view grow a little more as well. - if (getString(R.string.app_name).length() > "NewPipe".length()) { - final ViewGroup.LayoutParams layoutParams = - drawerHeaderBinding.drawerHeaderNewpipeTitle.getLayoutParams(); - layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; - drawerHeaderBinding.drawerHeaderNewpipeTitle.setLayoutParams(layoutParams); - drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxLines(2); - drawerHeaderBinding.drawerHeaderNewpipeTitle.setMinWidth(getResources() - .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width)); - drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxWidth(getResources() - .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width)); - } - } - - private void toggleServices() { - servicesShown = !servicesShown; - - drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group); - drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group); - drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group); - drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group); - - // Show up or down arrow - drawerHeaderBinding.drawerArrow.setImageResource( - servicesShown ? R.drawable.ic_arrow_drop_up : R.drawable.ic_arrow_drop_down); - - if (servicesShown) { - showServices(); - } else { - try { - addDrawerMenuForCurrentService(); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Showing main page tabs", e); - } - } - } - - private void showServices() { - for (final StreamingService s : NewPipe.getServices()) { - final String title = s.getServiceInfo().getName(); - - final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_services_group, s.getServiceId(), ORDER, title) - .setIcon(ServiceHelper.getIcon(s.getServiceId())); - - // peertube specifics - if (s.getServiceId() == 3) { - enhancePeertubeMenu(menuItem); - } - } - drawerLayoutBinding.navigation.getMenu() - .getItem(ServiceHelper.getSelectedServiceId(this)) - .setChecked(true); - } - - private void enhancePeertubeMenu(final MenuItem menuItem) { - final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance(); - menuItem.setTitle(currentInstance.getName()); - final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this)) - .getRoot(); - final List instances = PeertubeHelper.getInstanceList(this); - final List items = new ArrayList<>(); - int defaultSelect = 0; - for (final PeertubeInstance instance : instances) { - items.add(instance.getName()); - if (instance.getUrl().equals(currentInstance.getUrl())) { - defaultSelect = items.size() - 1; - } - } - final ArrayAdapter adapter = new ArrayAdapter<>(this, - R.layout.instance_spinner_item, items); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinner.setAdapter(adapter); - spinner.setSelection(defaultSelect, false); - spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(final AdapterView parent, final View view, - final int position, final long id) { - final PeertubeInstance newInstance = instances.get(position); - if (newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) { - return; - } - PeertubeHelper.selectInstance(newInstance, getApplicationContext()); - changeService(menuItem); - mainBinding.getRoot().closeDrawers(); - new Handler(Looper.getMainLooper()).postDelayed(() -> { - getSupportFragmentManager().popBackStack(null, - FragmentManager.POP_BACK_STACK_INCLUSIVE); - ActivityCompat.recreate(MainActivity.this); - }, 300); - } - - @Override - public void onNothingSelected(final AdapterView parent) { - - } - }); - menuItem.setActionView(spinner); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations()) { - StateSaver.clearStateFiles(); - } - if (broadcastReceiver != null) { - unregisterReceiver(broadcastReceiver); - } - } - - @Override - protected void onResume() { - // Change the date format to match the selected language on resume - Localization.initPrettyTime(Localization.resolvePrettyTime()); - super.onResume(); - - // Close drawer on return, and don't show animation, - // so it looks like the drawer isn't open when the user returns to MainActivity - mainBinding.getRoot().closeDrawer(GravityCompat.START, false); - try { - final int selectedServiceId = ServiceHelper.getSelectedServiceId(this); - final String selectedServiceName = NewPipe.getService(selectedServiceId) - .getServiceInfo().getName(); - drawerHeaderBinding.drawerHeaderServiceView.setText(selectedServiceName); - drawerHeaderBinding.drawerHeaderServiceIcon.setImageResource(ServiceHelper - .getIcon(selectedServiceId)); - - drawerHeaderBinding.drawerHeaderServiceView.post(() -> drawerHeaderBinding - .drawerHeaderServiceView.setSelected(true)); - drawerHeaderBinding.drawerHeaderActionButton.setContentDescription( - getString(R.string.drawer_header_description) + selectedServiceName); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e); - } - - if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { - if (DEBUG) { - Log.d(TAG, "Theme has changed, recreating activity..."); - } - sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply(); - ActivityCompat.recreate(this); - } - - if (sharedPreferences.getBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false)) { - if (DEBUG) { - Log.d(TAG, "main page has changed, recreating main fragment..."); - } - sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply(); - NavigationHelper.openMainActivity(this); - } - - final boolean isHistoryEnabled = sharedPreferences.getBoolean( - getString(R.string.enable_watch_history_key), true); - drawerLayoutBinding.navigation.getMenu().findItem(ITEM_ID_HISTORY) - .setVisible(isHistoryEnabled); - } - - @Override - protected void onNewIntent(final Intent intent) { - if (DEBUG) { - Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]"); - } - if (intent != null) { - // Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...) - // to not destroy the already created backstack - final String action = intent.getAction(); - if ((action != null && action.equals(Intent.ACTION_MAIN)) - && intent.hasCategory(Intent.CATEGORY_LAUNCHER)) { - return; - } - } - - super.onNewIntent(intent); - setIntent(intent); - handleIntent(intent); - } - - @Override - public boolean onKeyDown(final int keyCode, final KeyEvent event) { - final Fragment fragment = getSupportFragmentManager() - .findFragmentById(R.id.fragment_player_holder); - if (fragment instanceof OnKeyDownListener - && !bottomSheetHiddenOrCollapsed()) { - // Provide keyDown event to fragment which then sends this event - // to the main player service - return ((OnKeyDownListener) fragment).onKeyDown(keyCode) - || super.onKeyDown(keyCode, event); - } - return super.onKeyDown(keyCode, event); - } - - @Override - public void onBackPressed() { - if (DEBUG) { - Log.d(TAG, "onBackPressed() called"); - } - - if (DeviceUtils.isTv(this)) { - if (mainBinding.getRoot().isDrawerOpen(drawerLayoutBinding.navigation)) { - mainBinding.getRoot().closeDrawers(); - return; - } - } - - // In case bottomSheet is not visible on the screen or collapsed we can assume that the user - // interacts with a fragment inside fragment_holder so all back presses should be - // handled by it - if (bottomSheetHiddenOrCollapsed()) { - final FragmentManager fm = getSupportFragmentManager(); - final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); - // If current fragment implements BackPressable (i.e. can/wanna handle back press) - // delegate the back press to it - if (fragment instanceof BackPressable) { - if (((BackPressable) fragment).onBackPressed()) { - return; - } - } else if (fragment instanceof CommentRepliesFragment) { - // expand DetailsFragment if CommentRepliesFragment was opened - // to show the top level comments again - // Expand DetailsFragment if CommentRepliesFragment was opened - // and no other CommentRepliesFragments are on top of the back stack - // to show the top level comments again. - openDetailFragmentFromCommentReplies(fm, false); - } - - } else { - final Fragment fragmentPlayer = getSupportFragmentManager() - .findFragmentById(R.id.fragment_player_holder); - // If current fragment implements BackPressable (i.e. can/wanna handle back press) - // delegate the back press to it - if (fragmentPlayer instanceof BackPressable) { - if (!((BackPressable) fragmentPlayer).onBackPressed()) { - BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder) - .setState(BottomSheetBehavior.STATE_COLLAPSED); - } - return; - } - } - - if (getSupportFragmentManager().getBackStackEntryCount() == 1) { - finish(); - } else { - super.onBackPressed(); - } - } - - @Override - public void onRequestPermissionsResult(final int requestCode, - @NonNull final String[] permissions, - @NonNull final int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - for (final int i : grantResults) { - if (i == PackageManager.PERMISSION_DENIED) { - return; - } - } - switch (requestCode) { - case PermissionHelper.DOWNLOADS_REQUEST_CODE: - NavigationHelper.openDownloads(this); - break; - case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE: - final Fragment fragment = getSupportFragmentManager() - .findFragmentById(R.id.fragment_player_holder); - if (fragment instanceof VideoDetailFragment) { - ((VideoDetailFragment) fragment).openDownloadDialog(); - } - break; - case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE: - NotificationWorker.initialize(this); - break; - } - } - - /** - * Implement the following diagram behavior for the up button: - *

-     *              +---------------+
-     *              |  Main Screen  +----+
-     *              +-------+-------+    |
-     *                      |            |
-     *                      ▲ Up         | Search Button
-     *                      |            |
-     *                 +----+-----+      |
-     *    +------------+  Search  |◄-----+
-     *    |            +----+-----+
-     *    |   Open          |
-     *    |  something      ▲ Up
-     *    |                 |
-     *    |    +------------+-------------+
-     *    |    |                          |
-     *    |    |  Video    <->  Channel   |
-     *    +---►|  Channel  <->  Playlist  |
-     *         |  Video    <->  ....      |
-     *         |                          |
-     *         +--------------------------+
-     * 
- */ - private void onHomeButtonPressed() { - final FragmentManager fm = getSupportFragmentManager(); - final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); - - if (fragment instanceof CommentRepliesFragment) { - // Expand DetailsFragment if CommentRepliesFragment was opened - // and no other CommentRepliesFragments are on top of the back stack - // to show the top level comments again. - openDetailFragmentFromCommentReplies(fm, true); - } else if (!NavigationHelper.tryGotoSearchFragment(fm)) { - // If search fragment wasn't found in the backstack go to the main fragment - NavigationHelper.gotoMainFragment(fm); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]"); - } - super.onCreateOptionsMenu(menu); - - final Fragment fragment = - getSupportFragmentManager().findFragmentById(R.id.fragment_holder); - if (!(fragment instanceof SearchFragment)) { - toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE); - } - - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(false); - } - - updateDrawerNavigation(); - - return true; - } - - @Override - public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - if (DEBUG) { - Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]"); - } - - if (item.getItemId() == android.R.id.home) { - onHomeButtonPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - private void initFragments() { - if (DEBUG) { - Log.d(TAG, "initFragments() called"); - } - StateSaver.clearStateFiles(); - if (getIntent() != null && getIntent().hasExtra(Constants.KEY_LINK_TYPE)) { - // When user watch a video inside popup and then tries to open the video in main player - // while the app is closed he will see a blank fragment on place of kiosk. - // Let's open it first - if (getSupportFragmentManager().getBackStackEntryCount() == 0) { - NavigationHelper.openMainFragment(getSupportFragmentManager()); - } - - handleIntent(getIntent()); - } else { - NavigationHelper.gotoMainFragment(getSupportFragmentManager()); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void updateDrawerNavigation() { - if (getSupportActionBar() == null) { - return; - } - - final Fragment fragment = getSupportFragmentManager() - .findFragmentById(R.id.fragment_holder); - if (fragment instanceof MainFragment) { - getSupportActionBar().setDisplayHomeAsUpEnabled(false); - if (toggle != null) { - toggle.syncState(); - toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() - .open()); - mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); - } - } else { - mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> onHomeButtonPressed()); - } - } - - private void handleIntent(final Intent intent) { - try { - if (DEBUG) { - Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); - } - - if (intent.hasExtra(Constants.KEY_LINK_TYPE)) { - final String url = intent.getStringExtra(Constants.KEY_URL); - final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); - String title = intent.getStringExtra(Constants.KEY_TITLE); - if (title == null) { - title = ""; - } - - final StreamingService.LinkType linkType = ((StreamingService.LinkType) intent - .getSerializableExtra(Constants.KEY_LINK_TYPE)); - assert linkType != null; - switch (linkType) { - case STREAM: - final String intentCacheKey = intent.getStringExtra( - Player.PLAY_QUEUE_KEY); - final PlayQueue playQueue = intentCacheKey != null - ? SerializedCache.getInstance() - .take(intentCacheKey, PlayQueue.class) - : null; - - final boolean switchingPlayers = intent.getBooleanExtra( - VideoDetailFragment.KEY_SWITCHING_PLAYERS, false); - NavigationHelper.openVideoDetailFragment( - getApplicationContext(), getSupportFragmentManager(), - serviceId, url, title, playQueue, switchingPlayers); - break; - case CHANNEL: - NavigationHelper.openChannelFragment(getSupportFragmentManager(), - serviceId, url, title); - break; - case PLAYLIST: - NavigationHelper.openPlaylistFragment(getSupportFragmentManager(), - serviceId, url, title); - break; - } - } else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) { - String searchString = intent.getStringExtra(Constants.KEY_SEARCH_STRING); - if (searchString == null) { - searchString = ""; - } - final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); - NavigationHelper.openSearchFragment( - getSupportFragmentManager(), - serviceId, - searchString); - - } else { - NavigationHelper.gotoMainFragment(getSupportFragmentManager()); - } - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Handling intent", e); - } - } - - private void openMiniPlayerIfMissing() { - final Fragment fragmentPlayer = getSupportFragmentManager() - .findFragmentById(R.id.fragment_player_holder); - if (fragmentPlayer == null) { - // We still don't have a fragment attached to the activity. It can happen when a user - // started popup or background players without opening a stream inside the fragment. - // Adding it in a collapsed state (only mini player will be visible). - NavigationHelper.showMiniPlayer(getSupportFragmentManager()); - } - } - - private void openMiniPlayerUponPlayerStarted() { - if (getIntent().getSerializableExtra(Constants.KEY_LINK_TYPE) - == StreamingService.LinkType.STREAM) { - // handleIntent() already takes care of opening video detail fragment - // due to an intent containing a STREAM link - return; - } - - if (PlayerHolder.getInstance().isPlayerOpen()) { - // if the player is already open, no need for a broadcast receiver - openMiniPlayerIfMissing(); - } else { - // listen for player start intent being sent around - broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - if (Objects.equals(intent.getAction(), - VideoDetailFragment.ACTION_PLAYER_STARTED) - && PlayerHolder.getInstance().isPlayerOpen()) { - openMiniPlayerIfMissing(); - // At this point the player is added 100%, we can unregister. Other actions - // are useless since the fragment will not be removed after that. - unregisterReceiver(broadcastReceiver); - broadcastReceiver = null; - } - } - }; - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED); - ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter, - ContextCompat.RECEIVER_EXPORTED); - - // If the PlayerHolder is not bound yet, but the service is running, try to bind to it. - // Once the connection is established, the ACTION_PLAYER_STARTED will be sent. - PlayerHolder.getInstance().tryBindIfNeeded(this); - } - } - - private void openDetailFragmentFromCommentReplies( - @NonNull final FragmentManager fm, - final boolean popBackStack - ) { - // obtain the name of the fragment under the replies fragment that's going to be popped - @Nullable final String fragmentUnderEntryName; - if (fm.getBackStackEntryCount() < 2) { - fragmentUnderEntryName = null; - } else { - fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2) - .getName(); - } - - // the root comment is the comment for which the user opened the replies page - @Nullable final CommentRepliesFragment repliesFragment = - (CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG); - @Nullable final CommentsInfoItem rootComment = - repliesFragment == null ? null : repliesFragment.getCommentsInfoItem(); - - // sometimes this function pops the backstack, other times it's handled by the system - if (popBackStack) { - fm.popBackStackImmediate(); - } - - // only expand the bottom sheet back if there are no more nested comment replies fragments - // stacked under the one that is currently being popped - if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) { - return; - } - - final BottomSheetBehavior behavior = BottomSheetBehavior - .from(mainBinding.fragmentPlayerHolder); - // do not return to the comment if the details fragment was closed - if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { - return; - } - - // scroll to the root comment once the bottom sheet expansion animation is finished - behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull final View bottomSheet, - final int newState) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - final Fragment detailFragment = fm.findFragmentById( - R.id.fragment_player_holder); - if (detailFragment instanceof VideoDetailFragment && rootComment != null) { - // should always be the case - ((VideoDetailFragment) detailFragment).scrollToComment(rootComment); - } - behavior.removeBottomSheetCallback(this); - } - } - - @Override - public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { - // not needed, listener is removed once the sheet is expanded - } - }); - - behavior.setState(BottomSheetBehavior.STATE_EXPANDED); - } - - private boolean bottomSheetHiddenOrCollapsed() { - final BottomSheetBehavior bottomSheetBehavior = - BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder); - - final int sheetState = bottomSheetBehavior.getState(); - return sheetState == BottomSheetBehavior.STATE_HIDDEN - || sheetState == BottomSheetBehavior.STATE_COLLAPSED; - } - - private void showKeepAndroidDialog() { - final var prefs = PreferenceManager.getDefaultSharedPreferences(this); - final var lastCheckKey = getString(R.string.kao_last_checked_key); - final var lastCheck = Instant.ofEpochMilli(prefs.getLong(lastCheckKey, 0)); - final var now = Instant.now(); - - if (lastCheck.plus(30, ChronoUnit.DAYS).isBefore(now)) { - final String detailsUrl = getKeepAndroidOpenDetailsUrl(); - final var solutionUrl = "https://github.com/woheller69/FreeDroidWarn#solutions"; - - final var dialog = new AlertDialog.Builder(this) - .setTitle("Keep Android Open") - .setCancelable(false) - .setMessage(R.string.kao_dialog_warning) - .setPositiveButton(android.R.string.ok, (d, w) -> prefs.edit() - .putLong(lastCheckKey, now.toEpochMilli()) - .apply()) - .setNeutralButton(R.string.kao_solution, null) - .setNegativeButton(R.string.kao_dialog_more_info, null) - .show(); - - // If we use setNeutralButton/setNegativeButton, dialog will close after pressing the - // buttons, but we want it to close only when positive button is pressed - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, detailsUrl)); - dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, solutionUrl)); - } - } - - @NonNull - private static String getKeepAndroidOpenDetailsUrl() { - final var supportedLanguages = List.of("fr", "de", "ca", "es", "id", "it", "pl", - "pt", "cs", "sk", "fa", "ar", "tr", "el", "th", "ru", "uk", "ko", "zh", "ja"); - final String kaoBaseUrl = "https://keepandroidopen.org/"; - final var locale = Localization.getAppLocale(); - if (supportedLanguages.contains(locale.getLanguage())) { - if ("zh".equals(locale.getLanguage())) { - return kaoBaseUrl + ("TW".equals(locale.getCountry()) ? "zh-TW" : "zh-CN"); - } else { - return kaoBaseUrl + locale.getLanguage(); - } - } else { - return kaoBaseUrl; - } - } - - private void showApi23RequirementDialog() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return; // only show dialog on the devices that will stop being supported - } - - final var prefs = PreferenceManager.getDefaultSharedPreferences(this); - final var shownKey = getString(R.string.api23_requirement_dialog_shown_key); - if (prefs.getBoolean(shownKey, false)) { - return; // dialog was already shown in the past, no need to show it again - } - - final var dialog = new AlertDialog.Builder(this) - .setTitle(R.string.api23_requirement_dialog_title) - .setCancelable(false) - .setMessage(R.string.api23_requirement_dialog_message) - .setPositiveButton(android.R.string.ok, (d, w) -> prefs.edit() - .putBoolean(shownKey, true) - .apply()) - .setNegativeButton(R.string.api23_requirement_dialog_blogpost, null) - .show(); - - // If we use setNegativeButton, dialog will close after pressing the button, - // but we want it to close only when positive button is pressed - final var blogpostUrl = "https://newpipe.net/blog/pinned/announcement/drop-android-5/"; - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, blogpostUrl)); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt deleted file mode 100644 index 6527bd2ae..000000000 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe - -import android.content.Context -import androidx.room.Room.databaseBuilder -import kotlin.concurrent.Volatile -import org.schabi.newpipe.database.AppDatabase -import org.schabi.newpipe.database.Migrations.MIGRATION_1_2 -import org.schabi.newpipe.database.Migrations.MIGRATION_2_3 -import org.schabi.newpipe.database.Migrations.MIGRATION_3_4 -import org.schabi.newpipe.database.Migrations.MIGRATION_4_5 -import org.schabi.newpipe.database.Migrations.MIGRATION_5_6 -import org.schabi.newpipe.database.Migrations.MIGRATION_6_7 -import org.schabi.newpipe.database.Migrations.MIGRATION_7_8 -import org.schabi.newpipe.database.Migrations.MIGRATION_8_9 - -object NewPipeDatabase { - - @Volatile - private var databaseInstance: AppDatabase? = null - - private fun getDatabase(context: Context): AppDatabase { - return databaseBuilder( - context.applicationContext, - AppDatabase::class.java, - AppDatabase.Companion.DATABASE_NAME - ).addMigrations( - MIGRATION_1_2, - MIGRATION_2_3, - MIGRATION_3_4, - MIGRATION_4_5, - MIGRATION_5_6, - MIGRATION_6_7, - MIGRATION_7_8, - MIGRATION_8_9 - ).build() - } - - @JvmStatic - fun getInstance(context: Context): AppDatabase { - var result = databaseInstance - if (result == null) { - synchronized(NewPipeDatabase::class.java) { - result = databaseInstance - if (result == null) { - databaseInstance = getDatabase(context) - result = databaseInstance - } - } - } - - return result!! - } - - @JvmStatic - fun checkpoint() { - checkNotNull(databaseInstance) { "database is not initialized" } - val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null) - if (c.moveToFirst() && c.getInt(0) == 1) { - throw RuntimeException("Checkpoint was blocked from completing") - } - } - - @JvmStatic - fun close() { - if (databaseInstance != null) { - synchronized(NewPipeDatabase::class.java) { - if (databaseInstance != null) { - databaseInstance!!.close() - databaseInstance = null - } - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt deleted file mode 100644 index 4cdcc6c69..000000000 --- a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt +++ /dev/null @@ -1,186 +0,0 @@ -package org.schabi.newpipe - -import android.content.Context -import android.content.Intent -import android.util.Log -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.preference.PreferenceManager -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.Worker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonParserException -import java.io.IOException -import org.schabi.newpipe.extractor.downloader.Response -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import org.schabi.newpipe.util.ReleaseVersionUtil - -class NewVersionWorker( - context: Context, - workerParams: WorkerParameters -) : Worker(context, workerParams) { - - /** - * Method to compare the current and latest available app version. - * If a newer version is available, we show the update notification. - * - * @param versionName Name of new version - * @param apkLocationUrl Url with the new apk - * @param versionCode Code of new version - */ - private fun compareAppVersionAndShowNotification( - versionName: String, - apkLocationUrl: String?, - versionCode: Int - ) { - if (BuildConfig.VERSION_CODE >= versionCode) { - if (inputData.getBoolean(IS_MANUAL, false)) { - // Show toast stating that the app is up-to-date if the update check was manual. - ContextCompat.getMainExecutor(applicationContext).execute { - Toast.makeText( - applicationContext, - R.string.app_update_unavailable_toast, - Toast.LENGTH_SHORT - ).show() - } - } - return - } - - // A pending intent to open the apk location url in the browser. - val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri()) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - val pendingIntent = PendingIntentCompat.getActivity( - applicationContext, - 0, - intent, - 0, - false - ) - val channelId = applicationContext.getString(R.string.app_update_notification_channel_id) - val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.ic_newpipe_update) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setContentTitle( - applicationContext.getString(R.string.app_update_available_notification_title) - ) - .setContentText( - applicationContext.getString( - R.string.app_update_available_notification_text, - versionName - ) - ) - - val notificationManager = NotificationManagerCompat.from(applicationContext) - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(2000, notificationBuilder.build()) - } - } - - @Throws(IOException::class, ReCaptchaException::class) - private fun checkNewVersion() { - // Check if the current apk is a github one or not. - if (!ReleaseVersionUtil.isReleaseApk) { - return - } - - if (!inputData.getBoolean(IS_MANUAL, false)) { - val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - // Check if the last request has happened a certain time ago - // to reduce the number of API requests. - val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) - if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) { - return - } - } - - // Make a network request to get latest NewPipe data. - val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL) - handleResponse(response) - } - - private fun handleResponse(response: Response) { - val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - try { - // Store a timestamp which needs to be exceeded, - // before a new request to the API is made. - val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires")) - prefs.edit { - putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) - } - } catch (e: Exception) { - if (DEBUG) { - Log.w(TAG, "Could not extract and save new expiry date", e) - } - } - - // Parse the json from the response. - try { - val newpipeVersionInfo = JsonParser.`object`() - .from(response.responseBody()).getObject("flavors") - .getObject("newpipe") - - val versionName = newpipeVersionInfo.getString("version") - val versionCode = newpipeVersionInfo.getInt("version_code") - val apkLocationUrl = newpipeVersionInfo.getString("apk") - compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) - } catch (e: JsonParserException) { - // Most likely something is wrong in data received from NEWPIPE_API_URL. - // Do not alarm user and fail silently. - if (DEBUG) { - Log.w(TAG, "Could not get NewPipe API: invalid json", e) - } - } - } - - override fun doWork(): Result { - return try { - checkNewVersion() - Result.success() - } catch (e: IOException) { - Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) - Result.failure() - } catch (e: ReCaptchaException) { - Log.e(TAG, "ReCaptchaException should never happen here.", e) - Result.failure() - } - } - - companion object { - private val DEBUG = MainActivity.DEBUG - private val TAG = NewVersionWorker::class.java.simpleName - private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json" - private const val IS_MANUAL = "isManual" - - /** - * Start a new worker which checks if all conditions for performing a version check are met, - * fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe - * version and displays a notification about an available update if one is available. - *

- * Following conditions need to be met, before data is requested from the server: - * - * * The app is signed with the correct signing key (by TeamNewPipe / schabi). - * If the signing key differs from the one used upstream, the update cannot be installed. - * * The user enabled searching for and notifying about updates in the settings. - * * The app did not recently check for updates. - * We do not want to make unnecessary connections and DOS our servers. - */ - @JvmStatic - fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) { - val workRequest = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(IS_MANUAL to isManual)) - .build() - WorkManager.getInstance(context).enqueue(workRequest) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java deleted file mode 100644 index f0d1af81a..000000000 --- a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.schabi.newpipe; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; - -/* - * Copyright (C) Hans-Christoph Steiner 2016 - * PanicResponderActivity.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class PanicResponderActivity extends Activity { - public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; - - @SuppressLint("NewApi") - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - final Intent intent = getIntent(); - if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { - // TODO: Explicitly clear the search results - // once they are restored when the app restarts - // or if the app reloads the current video after being killed, - // that should be cleared also - ExitActivity.exitAndRemoveFromRecentApps(this); - } - - finishAndRemoveTask(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java deleted file mode 100644 index 3eeb912c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; - -import android.content.Context; -import android.view.ContextThemeWrapper; -import android.view.View; -import android.widget.PopupMenu; - -import androidx.fragment.app.FragmentManager; - -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.SparseItemUtil; - -import java.util.List; - -public final class QueueItemMenuUtil { - private QueueItemMenuUtil() { - } - - public static void openPopupMenu(final PlayQueue playQueue, - final PlayQueueItem item, - final View view, - final boolean hideDetails, - final FragmentManager fragmentManager, - final Context context) { - final ContextThemeWrapper themeWrapper = - new ContextThemeWrapper(context, R.style.DarkPopupMenu); - - final PopupMenu popupMenu = new PopupMenu(themeWrapper, view); - popupMenu.inflate(R.menu.menu_play_queue_item); - - if (hideDetails) { - popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false); - } - - popupMenu.setOnMenuItemClickListener(menuItem -> { - final int itemId = menuItem.getItemId(); - if (itemId == R.id.menu_item_remove) { - final int index = playQueue.indexOf(item); - playQueue.remove(index); - return true; - } else if (itemId == R.id.menu_item_details) { - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail(context, item.getServiceId(), - item.getUrl(), item.getTitle(), null, - false); - return true; - } else if (itemId == R.id.menu_item_append_playlist) { - PlaylistDialog.createCorrespondingDialog( - context, - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragmentManager, - "QueueItemMenuUtil@append_playlist" - ) - ); - - return true; - } else if (itemId == R.id.menu_item_channel_details) { - SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(), - item.getUrl(), item.getUploaderUrl(), - // An intent must be used here. - // Opening with FragmentManager transactions is not working, - // as PlayQueueActivity doesn't use fragments. - uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent( - context, item.getServiceId(), uploaderUrl, item.getUploader() - )); - return true; - } else if (itemId == R.id.menu_item_share) { - shareText(context, item.getTitle(), item.getUrl(), - item.getThumbnails()); - return true; - } else if (itemId == R.id.menu_item_download) { - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - info -> { - final DownloadDialog downloadDialog = new DownloadDialog(context, - info); - downloadDialog.show(fragmentManager, "downloadDialog"); - }); - return true; - } - return false; - }); - - popupMenu.show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java deleted file mode 100644 index 2997f937f..000000000 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ /dev/null @@ -1,1078 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.AUDIO; -import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.VIDEO; - -import android.annotation.SuppressLint; -import android.app.IntentService; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.ContextThemeWrapper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.Toast; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.app.NotificationCompat; -import androidx.core.app.ServiceCompat; -import androidx.core.math.MathUtils; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleOwner; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.livefront.bridge.Bridge; - -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.ListRadioIconItemBinding; -import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.download.LoadingDialog; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.StreamingService.LinkType; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.ChannelTabHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.urlfinder.UrlFinder; -import org.schabi.newpipe.views.FocusOverlayView; - -import java.io.Serializable; -import java.lang.ref.Reference; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -/** - * Get the url from the intent and open it in the chosen preferred player. - */ -public class RouterActivity extends AppCompatActivity { - protected final CompositeDisposable disposables = new CompositeDisposable(); - @State - protected int currentServiceId = -1; - @State - protected LinkType currentLinkType; - @State - protected int selectedRadioPosition = -1; - protected int selectedPreviously = -1; - protected String currentUrl; - private StreamingService currentService; - private boolean selectionIsDownload = false; - private boolean selectionIsAddToPlaylist = false; - private AlertDialog alertDialogChoice = null; - private FragmentManager.FragmentLifecycleCallbacks dismissListener = null; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - ThemeHelper.setDayNightMode(this); - setTheme(ThemeHelper.isLightThemeSelected(this) - ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); - - // Pass-through touch events to background activities - // so that our transparent window won't lock UI in the mean time - // network request is underway before showing PlaylistDialog or DownloadDialog - // (ref: https://stackoverflow.com/a/10606141) - getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); - - // Android never fails to impress us with a list of new restrictions per API. - // Starting with S (Android 12) one of the prerequisite conditions has to be met - // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in: - // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE - // For our present purpose it seems we can just set LayoutParams.alpha to 0 - // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs - final WindowManager.LayoutParams params = getWindow().getAttributes(); - params.alpha = 0f; - getWindow().setAttributes(params); - - super.onCreate(savedInstanceState); - Bridge.restoreInstanceState(this, savedInstanceState); - - // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates - // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments - // but those callbacks won't survive a config change - // Try an alternate approach to hook into FragmentManager instead, to that effect - // (ref: https://stackoverflow.com/a/44028453) - final FragmentManager fm = getSupportFragmentManager(); - if (dismissListener == null) { - dismissListener = new FragmentManager.FragmentLifecycleCallbacks() { - @Override - public void onFragmentDestroyed(@NonNull final FragmentManager fm, - @NonNull final Fragment f) { - super.onFragmentDestroyed(fm, f); - if (f instanceof DialogFragment && fm.getFragments().isEmpty()) { - // No more DialogFragments, we're done - finish(); - } - } - }; - } - fm.registerFragmentLifecycleCallbacks(dismissListener, false); - - if (TextUtils.isEmpty(currentUrl)) { - currentUrl = getUrl(getIntent()); - - if (TextUtils.isEmpty(currentUrl)) { - handleText(); - finish(); - } - } - } - - @Override - protected void onStop() { - super.onStop(); - // we need to dismiss the dialog before leaving the activity or we get leaks - if (alertDialogChoice != null) { - alertDialogChoice.dismiss(); - } - } - - @Override - protected void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } - - @Override - protected void onStart() { - super.onStart(); - - // Don't overlap the DialogFragment after rotating the screen - // If there's no DialogFragment, we're either starting afresh - // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change - if (getSupportFragmentManager().getFragments().isEmpty()) { - // Start over from scratch - handleUrl(currentUrl); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (dismissListener != null) { - getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener); - } - - disposables.clear(); - } - - @Override - public void finish() { - // allow the activity to recreate in case orientation changes - if (!isChangingConfigurations()) { - super.finish(); - } - } - - private void handleUrl(final String url) { - disposables.add(Observable - .fromCallable(() -> { - try { - if (currentServiceId == -1) { - currentService = NewPipe.getServiceByUrl(url); - currentServiceId = currentService.getServiceId(); - currentLinkType = currentService.getLinkTypeByUrl(url); - currentUrl = url; - } else { - currentService = NewPipe.getService(currentServiceId); - } - - // return whether the url was found to be supported or not - return currentLinkType != LinkType.NONE; - } catch (final ExtractionException e) { - // this can be reached only when the url is completely unsupported - return false; - } - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(isUrlSupported -> { - if (isUrlSupported) { - onSuccess(); - } else { - showUnsupportedUrlDialog(url); - } - }, throwable -> handleError(this, new ErrorInfo(throwable, - UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url, - null, url)))); - } - - /** - * @param context the context. It will be {@code finish()}ed at the end of the handling if it is - * an instance of {@link RouterActivity}. - * @param errorInfo the error information - */ - private static void handleError(final Context context, final ErrorInfo errorInfo) { - if (errorInfo.getRecaptchaUrl() != null) { - Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); - // Starting ReCaptcha Challenge Activity - final Intent intent = new Intent(context, ReCaptchaActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl()); - context.startActivity(intent); - } else if (errorInfo.isReportable()) { - ErrorUtil.createNotification(context, errorInfo); - } else { - // this exception does not usually indicate a problem that should be reported, - // so just show a toast instead of the notification - Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show(); - } - - if (context instanceof RouterActivity) { - ((RouterActivity) context).finish(); - } - } - - protected void showUnsupportedUrlDialog(final String url) { - final Context context = getThemeWrapperContext(); - new AlertDialog.Builder(context) - .setTitle(R.string.unsupported_url) - .setMessage(R.string.unsupported_url_dialog_message) - .setIcon(R.drawable.ic_share) - .setPositiveButton(R.string.open_in_browser, - (dialog, which) -> ShareUtils.openUrlInBrowser(this, url)) - .setNegativeButton(R.string.share, - (dialog, which) -> ShareUtils.shareText(this, "", url)) // no subject - .setNeutralButton(R.string.cancel, null) - .setOnDismissListener(dialog -> finish()) - .show(); - } - - protected void onSuccess() { - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(this); - - final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker( - getChoicesForService(currentService, currentLinkType), - preferences.getString(getString(R.string.preferred_open_action_key), - getString(R.string.preferred_open_action_default))); - - // Check for non-player related choices - if (choiceChecker.isAvailableAndSelected( - R.string.show_info_key, - R.string.download_key, - R.string.add_to_playlist_key)) { - handleChoice(choiceChecker.getSelectedChoiceKey()); - return; - } - // Check if the choice is player related - if (choiceChecker.isAvailableAndSelected( - R.string.video_player_key, - R.string.background_player_key, - R.string.popup_player_key, - R.string.enqueue_key)) { - - final String selectedChoice = choiceChecker.getSelectedChoiceKey(); - - final boolean isExtVideoEnabled = preferences.getBoolean( - getString(R.string.use_external_video_player_key), false); - final boolean isExtAudioEnabled = preferences.getBoolean( - getString(R.string.use_external_audio_player_key), false); - final boolean isVideoPlayerSelected = - selectedChoice.equals(getString(R.string.video_player_key)) - || selectedChoice.equals(getString(R.string.popup_player_key)); - final boolean isAudioPlayerSelected = - selectedChoice.equals(getString(R.string.background_player_key)); - final boolean isEnqueueSelected = - selectedChoice.equals(getString(R.string.enqueue_key)); - - if (currentLinkType != LinkType.STREAM - && ((isExtAudioEnabled && isAudioPlayerSelected) - || (isExtVideoEnabled && isVideoPlayerSelected)) - ) { - Toast.makeText(this, R.string.external_player_unsupported_link_type, - Toast.LENGTH_LONG).show(); - handleChoice(getString(R.string.show_info_key)); - return; - } - - final var capabilities = currentService.getServiceInfo().getMediaCapabilities(); - - // Check if the service supports the choice - if ((isVideoPlayerSelected && capabilities.contains(VIDEO)) - || (isAudioPlayerSelected && capabilities.contains(AUDIO)) - || (isEnqueueSelected && (capabilities.contains(VIDEO) - || capabilities.contains(AUDIO)))) { - handleChoice(selectedChoice); - } else { - handleChoice(getString(R.string.show_info_key)); - } - return; - } - - // Default / Ask always - final List availableChoices = choiceChecker.getAvailableChoices(); - switch (availableChoices.size()) { - case 1: - handleChoice(availableChoices.get(0).key); - break; - case 0: - handleChoice(getString(R.string.show_info_key)); - break; - default: - showDialog(availableChoices); - break; - } - } - - /** - * This is a helper class for checking if the choices are available and/or selected. - */ - class ChoiceAvailabilityChecker { - private final List availableChoices; - private final String selectedChoiceKey; - - ChoiceAvailabilityChecker( - @NonNull final List availableChoices, - @NonNull final String selectedChoiceKey) { - this.availableChoices = availableChoices; - this.selectedChoiceKey = selectedChoiceKey; - } - - public List getAvailableChoices() { - return availableChoices; - } - - public String getSelectedChoiceKey() { - return selectedChoiceKey; - } - - public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) { - return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected); - } - - public boolean isAvailableAndSelected(@StringRes final int wantedKey) { - final String wanted = getString(wantedKey); - // Check if the wanted option is selected - if (!selectedChoiceKey.equals(wanted)) { - return false; - } - // Check if it's available - return availableChoices.stream().anyMatch(item -> wanted.equals(item.key)); - } - } - - private void showDialog(final List choices) { - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - - final Context themeWrapperContext = getThemeWrapperContext(); - final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext); - - final SingleChoiceDialogViewBinding binding = - SingleChoiceDialogViewBinding.inflate(layoutInflater); - final RadioGroup radioGroup = binding.list; - - final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { - final int indexOfChild = radioGroup.indexOfChild( - radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())); - final AdapterChoiceItem choice = choices.get(indexOfChild); - - handleChoice(choice.key); - - // open future streams always like this one, because "always" button was used by user - if (which == DialogInterface.BUTTON_POSITIVE) { - preferences.edit() - .putString(getString(R.string.preferred_open_action_key), choice.key) - .apply(); - } - }; - - alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) - .setTitle(R.string.preferred_open_action_share_menu_title) - .setView(binding.getRoot()) - .setCancelable(true) - .setNegativeButton(R.string.just_once, dialogButtonsClickListener) - .setPositiveButton(R.string.always, dialogButtonsClickListener) - .setOnDismissListener(dialog -> { - if (!selectionIsDownload && !selectionIsAddToPlaylist) { - finish(); - } - }) - .create(); - - alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState( - alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1)); - - radioGroup.setOnCheckedChangeListener((group, checkedId) -> - setDialogButtonsState(alertDialogChoice, true)); - final View.OnClickListener radioButtonsClickListener = v -> { - final int indexOfChild = radioGroup.indexOfChild(v); - if (indexOfChild == -1) { - return; - } - - selectedPreviously = selectedRadioPosition; - selectedRadioPosition = indexOfChild; - - if (selectedPreviously == selectedRadioPosition) { - handleChoice(choices.get(selectedRadioPosition).key); - } - }; - - int id = 12345; - for (final AdapterChoiceItem item : choices) { - final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater) - .getRoot(); - radioButton.setText(item.description); - radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds( - AppCompatResources.getDrawable(themeWrapperContext, item.icon), - null, null, null); - radioButton.setChecked(false); - radioButton.setId(id++); - radioButton.setLayoutParams(new RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - radioButton.setOnClickListener(radioButtonsClickListener); - radioGroup.addView(radioButton); - } - - if (selectedRadioPosition == -1) { - final String lastSelectedPlayer = preferences.getString( - getString(R.string.preferred_open_action_last_selected_key), null); - if (!TextUtils.isEmpty(lastSelectedPlayer)) { - for (int i = 0; i < choices.size(); i++) { - final AdapterChoiceItem c = choices.get(i); - if (lastSelectedPlayer.equals(c.key)) { - selectedRadioPosition = i; - break; - } - } - } - } - - selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1); - if (selectedRadioPosition != -1) { - ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); - } - selectedPreviously = selectedRadioPosition; - - alertDialogChoice.show(); - - if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(alertDialogChoice); - } - } - - private List getChoicesForService(final StreamingService service, - final LinkType linkType) { - final AdapterChoiceItem showInfo = new AdapterChoiceItem( - getString(R.string.show_info_key), getString(R.string.show_info), - R.drawable.ic_info_outline); - final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( - getString(R.string.video_player_key), getString(R.string.video_player), - R.drawable.ic_play_arrow); - final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( - getString(R.string.background_player_key), getString(R.string.background_player), - R.drawable.ic_headset); - final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( - getString(R.string.popup_player_key), getString(R.string.popup_player), - R.drawable.ic_picture_in_picture); - - final List returnedItems = new ArrayList<>(); - returnedItems.add(showInfo); // Always present - - final var capabilities = service.getServiceInfo().getMediaCapabilities(); - - if (linkType == LinkType.STREAM || linkType == LinkType.PLAYLIST) { - if (capabilities.contains(VIDEO)) { - returnedItems.add(videoPlayer); - returnedItems.add(popupPlayer); - } - if (capabilities.contains(AUDIO)) { - returnedItems.add(backgroundPlayer); - } - - // Enqueue is only shown if the current queue is not empty. - // However, if the playqueue or the player is cleared after this item was chosen and - // while the item is extracted, it will automatically fall back to background player. - if (PlayerHolder.getInstance().getQueueSize() > 0) { - returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key), - getString(R.string.enqueue_stream), R.drawable.ic_add)); - } - - if (linkType == LinkType.STREAM) { - // download is redundant for linkType CHANNEL AND PLAYLIST - // (till playlist downloading is not supported ) - returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key), - getString(R.string.download), - R.drawable.ic_file_download)); - - // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType - // since those can not be added to a playlist - returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key), - getString(R.string.add_to_playlist), - R.drawable.ic_playlist_add)); - } - } else { - // LinkType.NONE is never present because it's filtered out before - // channels and playlist can be played as they contain a list of videos - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(this); - final boolean isExtVideoEnabled = preferences.getBoolean( - getString(R.string.use_external_video_player_key), false); - final boolean isExtAudioEnabled = preferences.getBoolean( - getString(R.string.use_external_audio_player_key), false); - - if (capabilities.contains(VIDEO) && !isExtVideoEnabled) { - returnedItems.add(videoPlayer); - returnedItems.add(popupPlayer); - } - if (capabilities.contains(AUDIO) && !isExtAudioEnabled) { - returnedItems.add(backgroundPlayer); - } - } - - return returnedItems; - } - - protected Context getThemeWrapperContext() { - return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this) - ? R.style.LightTheme : R.style.DarkTheme); - } - - private void setDialogButtonsState(final AlertDialog dialog, final boolean state) { - final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); - final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - if (negativeButton == null || positiveButton == null) { - return; - } - - negativeButton.setEnabled(state); - positiveButton.setEnabled(state); - } - - private void handleText() { - final String searchString = getIntent().getStringExtra(Intent.EXTRA_TEXT); - final int serviceId = getIntent().getIntExtra(Constants.KEY_SERVICE_ID, 0); - final Intent intent = new Intent(getThemeWrapperContext(), MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString); - } - - private void handleChoice(final String selectedChoiceKey) { - final List validChoicesList = Arrays.asList(getResources() - .getStringArray(R.array.preferred_open_action_values_list)); - if (validChoicesList.contains(selectedChoiceKey)) { - PreferenceManager.getDefaultSharedPreferences(this).edit() - .putString(getString( - R.string.preferred_open_action_last_selected_key), selectedChoiceKey) - .apply(); - } - - if (selectedChoiceKey.equals(getString(R.string.popup_player_key)) - && !PermissionHelper.isPopupEnabledElseAsk(this)) { - finish(); - return; - } - - if (selectedChoiceKey.equals(getString(R.string.download_key))) { - if (PermissionHelper.checkStoragePermissions(this, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - selectionIsDownload = true; - openDownloadDialog(); - } - return; - } - - if (selectedChoiceKey.equals(getString(R.string.add_to_playlist_key))) { - selectionIsAddToPlaylist = true; - openAddToPlaylistDialog(); - return; - } - - // stop and bypass FetcherService if InfoScreen was selected since - // StreamDetailFragment can fetch data itself - if (selectedChoiceKey.equals(getString(R.string.show_info_key)) - || canHandleChoiceLikeShowInfo(selectedChoiceKey)) { - disposables.add(Observable - .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(intent -> { - startActivity(intent); - finish(); - }, throwable -> handleError(this, new ErrorInfo(throwable, - UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl, - null, currentUrl))) - ); - return; - } - - final Intent intent = new Intent(this, FetcherService.class); - final Choice choice = new Choice(currentService.getServiceId(), currentLinkType, - currentUrl, selectedChoiceKey); - intent.putExtra(FetcherService.KEY_CHOICE, choice); - startService(intent); - - finish(); - } - - private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) { - if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) { - return false; - } - // "video player" can be handled like "show info" (because VideoDetailFragment can load - // the stream instead of FetcherService) when... - - // ...Autoplay is enabled - if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) { - return false; - } - - final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this) - .getBoolean(getString(R.string.use_external_video_player_key), false); - // ...it's not done via an external player - if (isExtVideoEnabled) { - return false; - } - - // ...the player is not running or in normal Video-mode/type - final PlayerType playerType = PlayerHolder.getInstance().getType(); - return playerType == null || playerType == PlayerType.MAIN; - } - - public static class PersistentFragment extends Fragment { - private WeakReference weakContext; - private final CompositeDisposable disposables = new CompositeDisposable(); - private int running = 0; - - private synchronized void inFlight(final boolean started) { - if (started) { - running++; - } else { - running--; - if (running <= 0) { - getActivityContext().ifPresent(context -> context.getSupportFragmentManager() - .beginTransaction().remove(this).commit()); - } - } - } - - @Override - public void onAttach(@NonNull final Context activityContext) { - super.onAttach(activityContext); - weakContext = new WeakReference<>((AppCompatActivity) activityContext); - } - - @Override - public void onDetach() { - super.onDetach(); - weakContext = null; - } - - @SuppressWarnings("deprecation") - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setRetainInstance(true); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposables.clear(); - } - - /** - * @return the activity context, if there is one and the activity is not finishing - */ - private Optional getActivityContext() { - return Optional.ofNullable(weakContext) - .map(Reference::get) - .filter(context -> !context.isFinishing()); - } - - // guard against IllegalStateException in calling DialogFragment.show() whilst in background - // (which could happen, say, when the user pressed the home button while waiting for - // the network request to return) when it internally calls FragmentTransaction.commit() - // after the FragmentManager has saved its states (isStateSaved() == true) - // (ref: https://stackoverflow.com/a/39813506) - private void runOnVisible(final Consumer runnable) { - getActivityContext().ifPresentOrElse(context -> { - if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { - context.runOnUiThread(() -> { - runnable.accept(context); - inFlight(false); - }); - } else { - getLifecycle().addObserver(new DefaultLifecycleObserver() { - @Override - public void onResume(@NonNull final LifecycleOwner owner) { - getLifecycle().removeObserver(this); - getActivityContext().ifPresentOrElse(context -> - context.runOnUiThread(() -> { - runnable.accept(context); - inFlight(false); - }), - () -> inFlight(false) - ); - } - }); - // this trick doesn't seem to work on Android 10+ (API 29) - // which places restrictions on starting activities from the background - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q - && !context.isChangingConfigurations()) { - // try to bring the activity back to front if minimised - final Intent i = new Intent(context, RouterActivity.class); - i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - startActivity(i); - } - } - - }, () -> - // this branch is executed if there is no activity context - inFlight(false) - ); - } - - Single pleaseWait(final Single single) { - // 'abuse' ambWith() here to cancel the toast for us when the wait is over - return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context -> - context.runOnUiThread(() -> { - // Getting the stream info usually takes a moment - // Notifying the user here to ensure that no confusion arises - final Toast toast = Toast.makeText(context, - getString(R.string.processing_may_take_a_moment), - Toast.LENGTH_LONG); - toast.show(); - emitter.setCancellable(toast::cancel); - })))); - } - - @SuppressLint("CheckResult") - private void openDownloadDialog(final int currentServiceId, final String currentUrl) { - inFlight(true); - final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title); - loadingDialog.show(getParentFragmentManager(), "loadingDialog"); - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .compose(this::pleaseWait) - .subscribe(result -> - runOnVisible(ctx -> { - loadingDialog.dismiss(); - final FragmentManager fm = ctx.getSupportFragmentManager(); - final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); - // dismiss listener to be handled by FragmentManager - downloadDialog.show(fm, "downloadDialog"); - } - ), throwable -> runOnVisible(ctx -> { - loadingDialog.dismiss(); - ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl); - }))); - } - - private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { - inFlight(true); - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .compose(this::pleaseWait) - .subscribe( - info -> getActivityContext().ifPresent(context -> - PlaylistDialog.createCorrespondingDialog(context, - List.of(new StreamEntity(info)), - playlistDialog -> runOnVisible(ctx -> { - // dismiss listener to be handled by FragmentManager - final FragmentManager fm = - ctx.getSupportFragmentManager(); - playlistDialog.show(fm, "addToPlaylistDialog"); - }) - )), - throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( - throwable, UserAction.REQUESTED_STREAM, - "Tried to add " + currentUrl + " to a playlist", - ((RouterActivity) ctx).currentService.getServiceId(), - currentUrl) - )) - ) - ); - } - } - - private void openAddToPlaylistDialog() { - getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl); - } - - private void openDownloadDialog() { - getPersistFragment().openDownloadDialog(currentServiceId, currentUrl); - } - - private PersistentFragment getPersistFragment() { - final FragmentManager fm = getSupportFragmentManager(); - PersistentFragment persistFragment = - (PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT"); - if (persistFragment == null) { - persistFragment = new PersistentFragment(); - fm.beginTransaction() - .add(persistFragment, "PERSIST_FRAGMENT") - .commitNow(); - } - return persistFragment; - } - - @Override - public void onRequestPermissionsResult(final int requestCode, - @NonNull final String[] permissions, - @NonNull final int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - for (final int i : grantResults) { - if (i == PackageManager.PERMISSION_DENIED) { - finish(); - return; - } - } - if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) { - openDownloadDialog(); - } - } - - private static class AdapterChoiceItem { - final String description; - final String key; - @DrawableRes - final int icon; - - AdapterChoiceItem(final String key, final String description, final int icon) { - this.key = key; - this.description = description; - this.icon = icon; - } - } - - private static class Choice implements Serializable { - final int serviceId; - final String url; - final String playerChoice; - final LinkType linkType; - - Choice(final int serviceId, final LinkType linkType, - final String url, final String playerChoice) { - this.serviceId = serviceId; - this.linkType = linkType; - this.url = url; - this.playerChoice = playerChoice; - } - - @NonNull - @Override - public String toString() { - return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice; - } - } - - public static class FetcherService extends IntentService { - - public static final String KEY_CHOICE = "key_choice"; - private static final int ID = 456; - private Disposable fetcher; - - public FetcherService() { - super(FetcherService.class.getSimpleName()); - } - - @Override - public void onCreate() { - super.onCreate(); - startForeground(ID, createNotification().build()); - } - - @Override - protected void onHandleIntent(@Nullable final Intent intent) { - if (intent == null) { - return; - } - - final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE); - if (!(serializable instanceof Choice)) { - return; - } - final Choice playerChoice = (Choice) serializable; - handleChoice(playerChoice); - } - - public void handleChoice(final Choice choice) { - Single single = null; - UserAction userAction = UserAction.SOMETHING_ELSE; - - switch (choice.linkType) { - case STREAM: - single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_STREAM; - break; - case CHANNEL: - single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_CHANNEL; - break; - case PLAYLIST: - single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_PLAYLIST; - break; - } - - - if (single != null) { - final UserAction finalUserAction = userAction; - final Consumer resultHandler = getResultHandler(choice); - fetcher = single - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - resultHandler.accept(info); - if (fetcher != null) { - fetcher.dispose(); - } - }, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction, - choice.url + " opened with " + choice.playerChoice, - choice.serviceId, choice.url))); - } - } - - public Consumer getResultHandler(final Choice choice) { - return info -> { - final String videoPlayerKey = getString(R.string.video_player_key); - final String backgroundPlayerKey = getString(R.string.background_player_key); - final String popupPlayerKey = getString(R.string.popup_player_key); - - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(this); - final boolean isExtVideoEnabled = preferences.getBoolean( - getString(R.string.use_external_video_player_key), false); - final boolean isExtAudioEnabled = preferences.getBoolean( - getString(R.string.use_external_audio_player_key), false); - - final PlayQueue playQueue; - if (info instanceof StreamInfo) { - if (choice.playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) { - NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info); - return; - } else if (choice.playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { - NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); - return; - } - playQueue = new SinglePlayQueue((StreamInfo) info); - } else if (info instanceof ChannelInfo) { - final Optional playableTab = ((ChannelInfo) info).getTabs() - .stream() - .filter(ChannelTabHelper::isStreamsTab) - .findFirst(); - - if (playableTab.isPresent()) { - playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get()); - } else { - return; // there is no playable tab - } - } else if (info instanceof PlaylistInfo) { - playQueue = new PlaylistPlayQueue((PlaylistInfo) info); - } else { - return; - } - - if (choice.playerChoice.equals(videoPlayerKey)) { - NavigationHelper.playOnMainPlayer(this, playQueue, false); - } else if (choice.playerChoice.equals(backgroundPlayerKey)) { - NavigationHelper.playOnBackgroundPlayer(this, playQueue, true); - } else if (choice.playerChoice.equals(popupPlayerKey)) { - NavigationHelper.playOnPopupPlayer(this, playQueue, true); - } else if (choice.playerChoice.equals(getString(R.string.enqueue_key))) { - NavigationHelper.enqueueOnPlayer(this, playQueue); - } - }; - } - - @Override - public void onDestroy() { - super.onDestroy(); - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - if (fetcher != null) { - fetcher.dispose(); - } - } - - private NotificationCompat.Builder createNotification() { - return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle( - getString(R.string.preferred_player_fetcher_notification_title)) - .setContentText( - getString(R.string.preferred_player_fetcher_notification_message)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - private String getUrl(final Intent intent) { - String foundUrl = null; - if (intent.getData() != null) { - // Called from another app - foundUrl = intent.getData().toString(); - } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) { - // Called from the share menu - final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT); - foundUrl = UrlFinder.firstUrlFromInput(extraText); - } - - return foundUrl; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt deleted file mode 100644 index ed5951f04..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ /dev/null @@ -1,260 +0,0 @@ -package org.schabi.newpipe.about - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.tabs.TabLayoutMediator -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ActivityAboutBinding -import org.schabi.newpipe.databinding.FragmentAboutBinding -import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.util.external_communication.ShareUtils - -class AboutActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - ThemeHelper.setTheme(this) - title = getString(R.string.title_activity_about) - - val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) - setContentView(aboutBinding.root) - setSupportActionBar(aboutBinding.aboutToolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - // Create the adapter that will return a fragment for each of the three - // primary sections of the activity. - val mAboutStateAdapter = AboutStateAdapter(this) - // Set up the ViewPager with the sections adapter. - aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter - TabLayoutMediator( - aboutBinding.aboutTabLayout, - aboutBinding.aboutViewPager2 - ) { tab, position -> - tab.setText(mAboutStateAdapter.getPageTitle(position)) - }.attach() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } - - /** - * A placeholder fragment containing a simple view. - */ - class AboutFragment : Fragment() { - private fun Button.openLink(@StringRes url: Int) { - setOnClickListener { - ShareUtils.openUrlInApp(context, requireContext().getString(url)) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - FragmentAboutBinding.inflate(inflater, container, false).apply { - aboutAppVersion.text = BuildConfig.VERSION_NAME - aboutGithubLink.openLink(R.string.github_url) - aboutDonationLink.openLink(R.string.donation_url) - aboutWebsiteLink.openLink(R.string.website_url) - aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) - faqLink.openLink(R.string.faq_url) - return root - } - } - } - - /** - * A [FragmentStateAdapter] that returns a fragment corresponding to - * one of the sections/tabs/pages. - */ - private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { - private val posAbout = 0 - private val posLicense = 1 - private val totalCount = 2 - - override fun createFragment(position: Int): Fragment { - return when (position) { - posAbout -> AboutFragment() - posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) - else -> error("Unknown position for ViewPager2") - } - } - - override fun getItemCount(): Int { - // Show 2 total pages. - return totalCount - } - - fun getPageTitle(position: Int): Int { - return when (position) { - posAbout -> R.string.tab_about - posLicense -> R.string.tab_licenses - else -> error("Unknown position for ViewPager2") - } - } - } - - companion object { - /** - * List of all software components. - */ - private val SOFTWARE_COMPONENTS = arrayListOf( - SoftwareComponent( - "ACRA", - "2013", - "Kevin Gaudin", - "https://github.com/ACRA/acra", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "AndroidX", - "2005 - 2011", - "The Android Open Source Project", - "https://developer.android.com/jetpack", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "ExoPlayer", - "2014 - 2020", - "Google, Inc.", - "https://github.com/google/ExoPlayer", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "GigaGet", - "2014 - 2015", - "Peter Cai", - "https://github.com/PaperAirplane-Dev-Team/GigaGet", - StandardLicenses.GPL3 - ), - SoftwareComponent( - "Groupie", - "2016", - "Lisa Wray", - "https://github.com/lisawray/groupie", - StandardLicenses.MIT - ), - SoftwareComponent( - "Android-State", - "2018", - "Evernote", - "https://github.com/Evernote/android-state", - StandardLicenses.EPL1 - ), - SoftwareComponent( - "Bridge", - "2021", - "Livefront", - "https://github.com/livefront/bridge", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "Jsoup", - "2009 - 2020", - "Jonathan Hedley", - "https://github.com/jhy/jsoup", - StandardLicenses.MIT - ), - SoftwareComponent( - "Markwon", - "2019", - "Dimitry Ivanov", - "https://github.com/noties/Markwon", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "Material Components for Android", - "2016 - 2020", - "Google, Inc.", - "https://github.com/material-components/material-components-android", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "NewPipe Extractor", - "2017 - 2020", - "Christian Schabesberger", - "https://github.com/TeamNewPipe/NewPipeExtractor", - StandardLicenses.GPL3 - ), - SoftwareComponent( - "NoNonsense-FilePicker", - "2016", - "Jonas Kalderstam", - "https://github.com/spacecowboy/NoNonsense-FilePicker", - StandardLicenses.MPL2 - ), - SoftwareComponent( - "OkHttp", - "2019", - "Square, Inc.", - "https://square.github.io/okhttp/", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "Coil", - "2023", - "Coil Contributors", - "https://coil-kt.github.io/coil/", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "PrettyTime", - "2012 - 2020", - "Lincoln Baxter, III", - "https://github.com/ocpsoft/prettytime", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "ProcessPhoenix", - "2015", - "Jake Wharton", - "https://github.com/JakeWharton/ProcessPhoenix", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "RxAndroid", - "2015", - "The RxAndroid authors", - "https://github.com/ReactiveX/RxAndroid", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "RxBinding", - "2015", - "Jake Wharton", - "https://github.com/JakeWharton/RxBinding", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "RxJava", - "2016 - 2020", - "RxJava Contributors", - "https://github.com/ReactiveX/RxJava", - StandardLicenses.APACHE2 - ), - SoftwareComponent( - "SearchPreference", - "2018", - "ByteHamster", - "https://github.com/ByteHamster/SearchPreference", - StandardLicenses.MIT - ) - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/License.kt b/app/src/main/java/org/schabi/newpipe/about/License.kt deleted file mode 100644 index fc50c646d..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/License.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.schabi.newpipe.about - -import android.os.Parcelable -import java.io.Serializable -import kotlinx.parcelize.Parcelize - -/** - * Class for storing information about a software license. - */ -@Parcelize -class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt deleted file mode 100644 index bd0632c13..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.schabi.newpipe.about - -import android.os.Bundle -import android.util.Base64 -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.WebView -import androidx.appcompat.app.AlertDialog -import androidx.core.os.BundleCompat -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FragmentLicensesBinding -import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding -import org.schabi.newpipe.ktx.parcelableArrayList -import org.schabi.newpipe.util.external_communication.ShareUtils - -/** - * Fragment containing the software licenses. - */ -class LicenseFragment : Fragment() { - private lateinit var softwareComponents: List - private var activeSoftwareComponent: SoftwareComponent? = null - private val compositeDisposable = CompositeDisposable() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - softwareComponents = arguments?.parcelableArrayList(ARG_COMPONENTS)!! - .sortedBy { it.name } // Sort components by name - activeSoftwareComponent = savedInstanceState?.let { - BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java) - } - } - - override fun onDestroy() { - compositeDisposable.dispose() - super.onDestroy() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentLicensesBinding.inflate(inflater, container, false) - binding.licensesAppReadLicense.setOnClickListener { - compositeDisposable.add( - showLicense(NEWPIPE_SOFTWARE_COMPONENT) - ) - } - for (component in softwareComponents) { - val componentBinding = ItemSoftwareComponentBinding - .inflate(inflater, container, false) - componentBinding.name.text = component.name - componentBinding.copyright.text = getString( - R.string.copyright, - component.years, - component.copyrightOwner, - component.license.abbreviation - ) - val root: View = componentBinding.root - root.tag = component - root.setOnClickListener { - compositeDisposable.add( - showLicense(component) - ) - } - binding.licensesSoftwareComponents.addView(root) - registerForContextMenu(root) - } - activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) } - return binding.root - } - - override fun onSaveInstanceState(savedInstanceState: Bundle) { - super.onSaveInstanceState(savedInstanceState) - activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) } - } - - private fun showLicense( - softwareComponent: SoftwareComponent - ): Disposable { - return if (context == null) { - Disposable.empty() - } else { - val context = requireContext() - activeSoftwareComponent = softwareComponent - Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { formattedLicense -> - val webViewData = Base64.encodeToString( - formattedLicense.toByteArray(), - Base64.NO_PADDING - ) - val webView = WebView(context) - webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") - - val builder = AlertDialog.Builder(requireContext()) - .setTitle(softwareComponent.name) - .setView(webView) - .setOnCancelListener { activeSoftwareComponent = null } - .setOnDismissListener { activeSoftwareComponent = null } - .setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() } - - if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) { - builder.setNeutralButton(R.string.open_website_license) { _, _ -> - ShareUtils.openUrlInApp(requireContext(), softwareComponent.link) - } - } - - builder.show() - } - } - } - - companion object { - private const val ARG_COMPONENTS = "components" - private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT" - private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent( - "NewPipe", - "2014-2023", - "Team NewPipe", - "https://newpipe.net/", - StandardLicenses.GPL3, - BuildConfig.VERSION_NAME - ) - - fun newInstance(softwareComponents: ArrayList): LicenseFragment { - val fragment = LicenseFragment() - fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents) - return fragment - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt deleted file mode 100644 index 32e4f812f..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.schabi.newpipe.about - -import android.content.Context -import java.io.IOException -import org.schabi.newpipe.R -import org.schabi.newpipe.util.ThemeHelper - -/** - * @param context the context to use - * @param license the license - * @return String which contains a HTML formatted license page - * styled according to the context's theme - */ -fun getFormattedLicense(context: Context, license: License): String { - try { - return context.assets.open(license.filename).bufferedReader().use { it.readText() } - // split the HTML file and insert the stylesheet into the HEAD of the file - .replace("", "") - } catch (e: IOException) { - throw IllegalArgumentException("Could not get license file: ${license.filename}", e) - } -} - -/** - * @param context the Android context - * @return String which is a CSS stylesheet according to the context's theme - */ -fun getLicenseStylesheet(context: Context): String { - val isLightTheme = ThemeHelper.isLightThemeSelected(context) - val licenseBackgroundColor = getHexRGBColor( - context, - if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color - ) - val licenseTextColor = getHexRGBColor( - context, - if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color - ) - val youtubePrimaryColor = getHexRGBColor( - context, - if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color - ) - return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" + - "a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}" -} - -/** - * Cast R.color to a hexadecimal color value. - * - * @param context the context to use - * @param color the color number from R.color - * @return a six characters long String with hexadecimal RGB values - */ -fun getHexRGBColor(context: Context, color: Int): String { - return context.getString(color).substring(3) -} diff --git a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt deleted file mode 100644 index a43ddfd5e..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.about - -import android.os.Parcelable -import java.io.Serializable -import kotlinx.parcelize.Parcelize - -@Parcelize -class SoftwareComponent -@JvmOverloads -constructor( - val name: String, - val years: String, - val copyrightOwner: String, - val link: String, - val license: License, - val version: String? = null -) : Parcelable, Serializable diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt deleted file mode 100644 index c5b9618fe..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.schabi.newpipe.about - -/** - * Class containing information about standard software licenses. - */ -object StandardLicenses { - @JvmField - val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html") - - @JvmField - val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html") - - @JvmField - val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html") - - @JvmField - val MIT = License("MIT License", "MIT", "mit.html") - - @JvmField - val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html") -} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt deleted file mode 100644 index 286eddf7b..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import org.schabi.newpipe.database.feed.dao.FeedDAO -import org.schabi.newpipe.database.feed.dao.FeedGroupDAO -import org.schabi.newpipe.database.feed.model.FeedEntity -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.history.dao.SearchHistoryDAO -import org.schabi.newpipe.database.history.dao.StreamHistoryDAO -import org.schabi.newpipe.database.history.model.SearchHistoryEntry -import org.schabi.newpipe.database.history.model.StreamHistoryEntity -import org.schabi.newpipe.database.playlist.dao.PlaylistDAO -import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO -import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO -import org.schabi.newpipe.database.playlist.model.PlaylistEntity -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity -import org.schabi.newpipe.database.stream.dao.StreamDAO -import org.schabi.newpipe.database.stream.dao.StreamStateDAO -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity -import org.schabi.newpipe.database.subscription.SubscriptionDAO -import org.schabi.newpipe.database.subscription.SubscriptionEntity - -@TypeConverters(Converters::class) -@Database( - version = Migrations.DB_VER_9, - entities = [ - SubscriptionEntity::class, - SearchHistoryEntry::class, - StreamEntity::class, - StreamHistoryEntity::class, - StreamStateEntity::class, - PlaylistEntity::class, - PlaylistStreamEntity::class, - PlaylistRemoteEntity::class, - FeedEntity::class, - FeedGroupEntity::class, - FeedGroupSubscriptionEntity::class, - FeedLastUpdatedEntity::class - ] -) -abstract class AppDatabase : RoomDatabase() { - abstract fun feedDAO(): FeedDAO - abstract fun feedGroupDAO(): FeedGroupDAO - abstract fun playlistDAO(): PlaylistDAO - abstract fun playlistRemoteDAO(): PlaylistRemoteDAO - abstract fun playlistStreamDAO(): PlaylistStreamDAO - abstract fun searchHistoryDAO(): SearchHistoryDAO - abstract fun streamDAO(): StreamDAO - abstract fun streamHistoryDAO(): StreamHistoryDAO - abstract fun streamStateDAO(): StreamStateDAO - abstract fun subscriptionDAO(): SubscriptionDAO - - companion object { - const val DATABASE_NAME: String = "newpipe.db" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt deleted file mode 100644 index 74c7cc87c..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Update -import io.reactivex.rxjava3.core.Flowable - -@Dao -interface BasicDAO { - - /* Inserts */ - @Insert - fun insert(entity: Entity): Long - - @Insert - fun insertAll(entities: Collection): List - - /* Searches */ - fun getAll(): Flowable> - - fun listByService(serviceId: Int): Flowable> - - /* Deletes */ - @Delete - fun delete(entity: Entity) - - fun deleteAll(): Int - - /* Updates */ - @Update - fun update(entity: Entity): Int - - @Update - fun update(entities: Collection) -} diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt deleted file mode 100644 index f9cbb1de2..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.database - -import androidx.room.TypeConverter -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -class Converters { - /** - * Convert a long value to a [OffsetDateTime]. - * - * @param value the long value - * @return the `OffsetDateTime` - */ - @TypeConverter - fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? { - return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) } - } - - /** - * Convert a [OffsetDateTime] to a long value. - * - * @param offsetDateTime the `OffsetDateTime` - * @return the long value - */ - @TypeConverter - fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? { - return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() - } - - @TypeConverter - fun streamTypeOf(value: String): StreamType { - return StreamType.valueOf(value) - } - - @TypeConverter - fun stringOf(streamType: StreamType): String { - return streamType.name - } - - @TypeConverter - fun integerOf(feedGroupIcon: FeedGroupIcon): Int { - return feedGroupIcon.id - } - - @TypeConverter - fun feedGroupIconOf(id: Int): FeedGroupIcon { - return FeedGroupIcon.entries.first { it.id == id } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt deleted file mode 100644 index 944b247bf..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database - -interface LocalItem { - val localItemType: LocalItemType - - enum class LocalItemType { - PLAYLIST_LOCAL_ITEM, - PLAYLIST_REMOTE_ITEM, - - PLAYLIST_STREAM_ITEM, - STATISTIC_STREAM_ITEM - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.kt b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt deleted file mode 100644 index 414f74893..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.kt +++ /dev/null @@ -1,351 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database - -import android.util.Log -import androidx.room.migration.Migration -import org.schabi.newpipe.MainActivity - -object Migrations { - - // /////////////////////////////////////////////////////////////////////// // - // Test new migrations manually by importing a database from daily usage // - // and checking if the migration works (Use the Database Inspector // - // https://developer.android.com/studio/inspect/database). // - // If you add a migration point it out in the pull request, so that // - // others remember to test it themselves. // - // /////////////////////////////////////////////////////////////////////// // - - const val DB_VER_1 = 1 - const val DB_VER_2 = 2 - const val DB_VER_3 = 3 - const val DB_VER_4 = 4 - const val DB_VER_5 = 5 - const val DB_VER_6 = 6 - const val DB_VER_7 = 7 - const val DB_VER_8 = 8 - const val DB_VER_9 = 9 - - private val TAG = Migrations::class.java.getName() - private val isDebug = MainActivity.DEBUG - - val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { db -> - if (isDebug) { - Log.d(TAG, "Start migrating database") - } - - /* - * Unfortunately these queries must be hardcoded due to the possibility of - * schema and names changing at a later date, thus invalidating the older migration - * scripts if they are not hardcoded. - * */ - - // Not much we can do about this, since room doesn't create tables before migration. - // It's either this or blasting the entire database anew. - db.execSQL( - "CREATE INDEX `index_search_history_search` " + - "ON `search_history` (`search`)" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `streams` " + - "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " + - "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " + - "`thumbnail_url` TEXT)" - ) - db.execSQL( - "CREATE UNIQUE INDEX `index_streams_service_id_url` " + - "ON `streams` (`service_id`, `url`)" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `stream_history` " + - "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " + - "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " + - "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + - "ON UPDATE CASCADE ON DELETE CASCADE )" - ) - db.execSQL( - "CREATE INDEX `index_stream_history_stream_id` " + - "ON `stream_history` (`stream_id`)" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `stream_state` " + - "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " + - "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " + - "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `playlists` " + - "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`name` TEXT, `thumbnail_url` TEXT)" - ) - db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)") - db.execSQL( - "CREATE TABLE IF NOT EXISTS `playlist_stream_join` " + - "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " + - "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " + - "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + - "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" - ) - db.execSQL( - "CREATE UNIQUE INDEX " + - "`index_playlist_stream_join_playlist_id_join_index` " + - "ON `playlist_stream_join` (`playlist_id`, `join_index`)" - ) - db.execSQL( - "CREATE INDEX `index_playlist_stream_join_stream_id` " + - "ON `playlist_stream_join` (`stream_id`)" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `remote_playlists` " + - "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + - "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)" - ) - db.execSQL( - "CREATE INDEX `index_remote_playlists_name` " + - "ON `remote_playlists` (`name`)" - ) - db.execSQL( - "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + - "ON `remote_playlists` (`service_id`, `url`)" - ) - - // Populate streams table with existing entries in watch history - // Latest data first, thus ignoring older entries with the same indices - db.execSQL( - "INSERT OR IGNORE INTO streams (service_id, url, title, " + - "stream_type, duration, uploader, thumbnail_url) " + - - "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " + - "uploader, thumbnail_url " + - - "FROM watch_history " + - "ORDER BY creation_date DESC" - ) - - // Once the streams have PKs, join them with the normalized history table - // and populate it with the remaining data from watch history - db.execSQL( - "INSERT INTO stream_history (stream_id, access_date, repeat_count)" + - "SELECT uid, creation_date, 1 " + - "FROM watch_history INNER JOIN streams " + - "ON watch_history.service_id == streams.service_id " + - "AND watch_history.url == streams.url " + - "ORDER BY creation_date DESC" - ) - - db.execSQL("DROP TABLE IF EXISTS watch_history") - - if (isDebug) { - Log.d(TAG, "Stop migrating database") - } - } - - val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { db -> - // Add NOT NULLs and new fields - db.execSQL( - "CREATE TABLE IF NOT EXISTS streams_new " + - "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " + - "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " + - "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " + - "textual_upload_date TEXT, upload_date INTEGER, " + - "is_upload_date_approximation INTEGER)" - ) - - db.execSQL( - "INSERT INTO streams_new (uid, service_id, url, title, stream_type, " + - "duration, uploader, thumbnail_url, view_count, textual_upload_date, " + - "upload_date, is_upload_date_approximation) " + - - "SELECT uid, service_id, url, ifnull(title, ''), " + - "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " + - "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " + - - "FROM streams WHERE url IS NOT NULL" - ) - - db.execSQL("DROP TABLE streams") - db.execSQL("ALTER TABLE streams_new RENAME TO streams") - db.execSQL( - "CREATE UNIQUE INDEX index_streams_service_id_url " + - "ON streams (service_id, url)" - ) - - // Tables for feed feature - db.execSQL( - "CREATE TABLE IF NOT EXISTS feed " + - "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + - "PRIMARY KEY(stream_id, subscription_id), " + - "FOREIGN KEY(stream_id) REFERENCES streams(uid) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + - "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" - ) - db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)") - db.execSQL( - "CREATE TABLE IF NOT EXISTS feed_group " + - "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " + - "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)" - ) - db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)") - db.execSQL( - "CREATE TABLE IF NOT EXISTS feed_group_subscription_join " + - "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + - "PRIMARY KEY(group_id, subscription_id), " + - "FOREIGN KEY(group_id) REFERENCES feed_group(uid) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + - "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" - ) - db.execSQL( - "CREATE INDEX index_feed_group_subscription_join_subscription_id " + - "ON feed_group_subscription_join (subscription_id)" - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS feed_last_updated " + - "(subscription_id INTEGER NOT NULL, last_updated INTEGER, " + - "PRIMARY KEY(subscription_id), " + - "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + - "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" - ) - } - - val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { db -> - db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT") - } - - val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db -> - db.execSQL( - "ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " + - "INTEGER NOT NULL DEFAULT 0" - ) - } - - val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db -> - db.execSQL( - "ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " + - "INTEGER NOT NULL DEFAULT 0" - ) - } - - val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db -> - // Create a new column thumbnail_stream_id - db.execSQL( - "ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " + - "INTEGER NOT NULL DEFAULT -1" - ) - - // Migrate the thumbnail_url to the thumbnail_stream_id - db.execSQL( - "UPDATE playlists SET thumbnail_stream_id = (" + - " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" + - " FROM (" + - " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" + - " FROM playlists p" + - " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" + - " LEFT JOIN streams s ON s.uid = ps.stream_id" + - " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" + - " WHERE playlist_uid = playlists.uid)" - ) - - // Remove the thumbnail_url field in the playlist table - db.execSQL( - "CREATE TABLE IF NOT EXISTS `playlists_new`" + - "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "name TEXT, " + - "is_thumbnail_permanent INTEGER NOT NULL, " + - "thumbnail_stream_id INTEGER NOT NULL)" - ) - - db.execSQL( - "INSERT INTO playlists_new" + - " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " + - " FROM playlists" - ) - - db.execSQL("DROP TABLE playlists") - db.execSQL("ALTER TABLE playlists_new RENAME TO playlists") - db.execSQL( - "CREATE INDEX IF NOT EXISTS " + - "`index_playlists_name` ON `playlists` (`name`)" - ) - } - - val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { db -> - db.execSQL( - "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " + - "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)" - ) - db.execSQL("UPDATE search_history SET search = trim(search)") - } - - val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { db -> - try { - db.beginTransaction() - - // Update playlists. - // Create a temp table to initialize display_index. - db.execSQL( - "CREATE TABLE `playlists_tmp` " + - "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " + - "`thumbnail_stream_id` INTEGER NOT NULL, " + - "`display_index` INTEGER NOT NULL)" - ) - db.execSQL( - "INSERT INTO `playlists_tmp` " + - "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + - "`display_index`) " + - "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + - "-1 " + - "FROM `playlists`" - ) - - // Replace the old table, note that this also removes the index on the name which - // we don't need anymore. - db.execSQL("DROP TABLE `playlists`") - db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`") - - // Update remote_playlists. - // Create a temp table to initialize display_index. - db.execSQL( - "CREATE TABLE `remote_playlists_tmp` " + - "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + - "`thumbnail_url` TEXT, `uploader` TEXT, " + - "`display_index` INTEGER NOT NULL," + - "`stream_count` INTEGER)" - ) - db.execSQL( - "INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " + - "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " + - "`stream_count`)" + - "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " + - "-1, `stream_count` FROM `remote_playlists`" - ) - - // Replace the old table, note that this also removes the index on the name which - // we don't need anymore. - db.execSQL("DROP TABLE `remote_playlists`") - db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`") - - // Create index on the new table. - db.execSQL( - "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + - "ON `remote_playlists` (`service_id`, `url`)" - ) - - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt deleted file mode 100644 index 5861fa767..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ /dev/null @@ -1,237 +0,0 @@ -package org.schabi.newpipe.database.feed.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Maybe -import java.time.OffsetDateTime -import org.schabi.newpipe.database.feed.model.FeedEntity -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.stream.StreamWithState -import org.schabi.newpipe.database.stream.model.StreamStateEntity -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionEntity - -@Dao -abstract class FeedDAO { - @Query("DELETE FROM feed") - abstract fun deleteAll(): Int - - /** - * @param groupId the group id to get feed streams of; use - * [FeedGroupEntity.GROUP_ALL_ID] to not filter by group - * @param includePlayed if false, only return all of the live, never-played or non-finished - * feed streams (see `@see` items); if true no filter is applied - * @param uploadDateBefore get only streams uploaded before this date (useful to filter out - * future streams); use null to not filter by upload date - * @return the feed streams filtered according to the conditions provided in the parameters - * @see StreamStateEntity.isFinished() - * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS - * @see StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS - */ - @Query( - """ - SELECT s.*, sst.progress_time - FROM streams s - - LEFT JOIN stream_state sst - ON s.uid = sst.stream_id - - LEFT JOIN stream_history sh - ON s.uid = sh.stream_id - - INNER JOIN feed f - ON s.uid = f.stream_id - - LEFT JOIN feed_group_subscription_join fgs - ON ( - :groupId <> ${FeedGroupEntity.GROUP_ALL_ID} - AND fgs.subscription_id = f.subscription_id - ) - - WHERE ( - :groupId = ${FeedGroupEntity.GROUP_ALL_ID} - OR fgs.group_id = :groupId - ) - AND ( - :includePlayed - OR sh.stream_id IS NULL - OR sst.stream_id IS NULL - OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} - OR sst.progress_time < s.duration * 1000 * 3 / 4 - OR s.stream_type = 'LIVE_STREAM' - OR s.stream_type = 'AUDIO_LIVE_STREAM' - ) - AND ( - :includePartiallyPlayed - OR sh.stream_id IS NULL - OR sst.stream_id IS NULL - OR (sst.progress_time <= ${StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} - AND sst.progress_time <= s.duration * 1000 / 4) - OR (sst.progress_time >= s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} - AND sst.progress_time >= s.duration * 1000 * 3 / 4) - ) - AND ( - :uploadDateBefore IS NULL - OR s.upload_date IS NULL - OR s.upload_date < :uploadDateBefore - ) - - ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 - """ - ) - abstract fun getStreams( - groupId: Long, - includePlayed: Boolean, - includePartiallyPlayed: Boolean, - uploadDateBefore: OffsetDateTime? - ): Maybe> - - /** - * Remove links to streams that are older than the given date - * **but keep at least one stream per uploader**. - * - * One stream per uploader is kept because it is needed as reference - * when fetching new streams to check if they are new or not. - * @param offsetDateTime the newest date to keep, older streams are removed - */ - @Query( - """ - DELETE FROM feed - WHERE feed.stream_id IN (SELECT uid from ( - SELECT s.uid, - (SELECT MAX(upload_date) - FROM streams s1 - INNER JOIN feed f1 - ON s1.uid = f1.stream_id - WHERE f1.subscription_id = f.subscription_id) max_upload_date - FROM streams s - INNER JOIN feed f - ON s.uid = f.stream_id - - WHERE s.upload_date < :offsetDateTime - AND s.upload_date <> max_upload_date)) - """ - ) - abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime) - - @Query( - """ - DELETE FROM feed - - WHERE feed.subscription_id = :subscriptionId - - AND feed.stream_id IN ( - SELECT s.uid FROM streams s - - INNER JOIN feed f - ON s.uid = f.stream_id - - WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM" - ) - """ - ) - abstract fun unlinkOldLivestreams(subscriptionId: Long) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract fun insert(feedEntity: FeedEntity) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract fun insertAll(entities: List): List - - @Insert(onConflict = OnConflictStrategy.IGNORE) - internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long - - @Update(onConflict = OnConflictStrategy.IGNORE) - internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity) - - @Transaction - open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) { - val id = insertLastUpdated(lastUpdatedEntity) - - if (id == -1L) { - updateLastUpdated(lastUpdatedEntity) - } - } - - @Query( - """ - SELECT MIN(lu.last_updated) FROM feed_last_updated lu - - INNER JOIN feed_group_subscription_join fgs - ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId - """ - ) - abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> - - @Query("SELECT MIN(last_updated) FROM feed_last_updated") - abstract fun oldestSubscriptionUpdateFromAll(): Flowable> - - @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL") - abstract fun notLoadedCount(): Flowable - - @Query( - """ - SELECT COUNT(*) FROM subscriptions s - - INNER JOIN feed_group_subscription_join fgs - ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId - - LEFT JOIN feed_last_updated lu - ON s.uid = lu.subscription_id - - WHERE lu.last_updated IS NULL - """ - ) - abstract fun notLoadedCountForGroup(groupId: Long): Flowable - - @Query( - """ - SELECT s.* FROM subscriptions s - - LEFT JOIN feed_last_updated lu - ON s.uid = lu.subscription_id - - WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold - """ - ) - abstract fun getAllOutdated(outdatedThreshold: OffsetDateTime): Flowable> - - @Query( - """ - SELECT s.* FROM subscriptions s - - INNER JOIN feed_group_subscription_join fgs - ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId - - LEFT JOIN feed_last_updated lu - ON s.uid = lu.subscription_id - - WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold - """ - ) - abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable> - - @Query( - """ - SELECT s.* FROM subscriptions s - - LEFT JOIN feed_last_updated lu - ON s.uid = lu.subscription_id - - WHERE - (lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold) - AND s.notification_mode = :notificationMode - """ - ) - abstract fun getOutdatedWithNotificationMode( - outdatedThreshold: OffsetDateTime, - @NotificationMode notificationMode: Int - ): Flowable> -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt deleted file mode 100644 index 217eef03f..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.schabi.newpipe.database.feed.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Maybe -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity - -@Dao -abstract class FeedGroupDAO { - - @Query("SELECT * FROM feed_group ORDER BY sort_order ASC") - abstract fun getAll(): Flowable> - - @Query("SELECT * FROM feed_group WHERE uid = :groupId") - abstract fun getGroup(groupId: Long): Maybe - - @Transaction - open fun insert(feedGroupEntity: FeedGroupEntity): Long { - val nextSortOrder = nextSortOrder() - feedGroupEntity.sortOrder = nextSortOrder - return insertInternal(feedGroupEntity) - } - - @Update(onConflict = OnConflictStrategy.IGNORE) - abstract fun update(feedGroupEntity: FeedGroupEntity): Int - - @Query("DELETE FROM feed_group") - abstract fun deleteAll(): Int - - @Query("DELETE FROM feed_group WHERE uid = :groupId") - abstract fun delete(groupId: Long): Int - - @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") - abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> - - @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") - abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract fun insertSubscriptionsToGroup(entities: List): List - - @Transaction - open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) { - deleteSubscriptionsFromGroup(groupId) - insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) }) - } - - @Transaction - open fun updateOrder(orderMap: Map) { - orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) } - } - - @Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId") - abstract fun updateOrder(groupId: Long, sortOrder: Long): Int - - @Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group") - protected abstract fun nextSortOrder(): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt deleted file mode 100644 index 86568bc90..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.database.feed.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE -import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID -import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.subscription.SubscriptionEntity - -@Entity( - tableName = FEED_TABLE, - primaryKeys = [STREAM_ID, SUBSCRIPTION_ID], - indices = [Index(SUBSCRIPTION_ID)], - foreignKeys = [ - ForeignKey( - entity = StreamEntity::class, - parentColumns = [StreamEntity.STREAM_ID], - childColumns = [STREAM_ID], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - deferred = true - ), - ForeignKey( - entity = SubscriptionEntity::class, - parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], - childColumns = [SUBSCRIPTION_ID], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - deferred = true - ) - ] -) -data class FeedEntity( - @ColumnInfo(name = STREAM_ID) - var streamId: Long, - - @ColumnInfo(name = SUBSCRIPTION_ID) - var subscriptionId: Long -) { - - companion object { - const val FEED_TABLE = "feed" - - const val STREAM_ID = "stream_id" - const val SUBSCRIPTION_ID = "subscription_id" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt deleted file mode 100644 index 1dd26946a..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.schabi.newpipe.database.feed.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE -import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -@Entity( - tableName = FEED_GROUP_TABLE, - indices = [Index(SORT_ORDER)] -) -data class FeedGroupEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = ID) - val uid: Long, - - @ColumnInfo(name = NAME) - var name: String, - - @ColumnInfo(name = ICON) - var icon: FeedGroupIcon, - - @ColumnInfo(name = SORT_ORDER) - var sortOrder: Long = -1 -) { - companion object { - const val FEED_GROUP_TABLE = "feed_group" - - const val ID = "uid" - const val NAME = "name" - const val ICON = "icon_id" - const val SORT_ORDER = "sort_order" - - const val GROUP_ALL_ID = -1L - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt deleted file mode 100644 index 6dac3c89c..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.database.feed.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID -import org.schabi.newpipe.database.subscription.SubscriptionEntity - -@Entity( - tableName = FEED_GROUP_SUBSCRIPTION_TABLE, - primaryKeys = [GROUP_ID, SUBSCRIPTION_ID], - indices = [Index(SUBSCRIPTION_ID)], - foreignKeys = [ - ForeignKey( - entity = FeedGroupEntity::class, - parentColumns = [FeedGroupEntity.ID], - childColumns = [GROUP_ID], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - deferred = true - ), - - ForeignKey( - entity = SubscriptionEntity::class, - parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], - childColumns = [SUBSCRIPTION_ID], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - deferred = true - ) - ] -) -data class FeedGroupSubscriptionEntity( - @ColumnInfo(name = GROUP_ID) - var feedGroupId: Long, - - @ColumnInfo(name = SUBSCRIPTION_ID) - var subscriptionId: Long -) { - - companion object { - const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join" - - const val GROUP_ID = "group_id" - const val SUBSCRIPTION_ID = "subscription_id" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt deleted file mode 100644 index fc0ee6742..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.schabi.newpipe.database.feed.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import java.time.OffsetDateTime -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID -import org.schabi.newpipe.database.subscription.SubscriptionEntity - -@Entity( - tableName = FEED_LAST_UPDATED_TABLE, - foreignKeys = [ - ForeignKey( - entity = SubscriptionEntity::class, - parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], - childColumns = [SUBSCRIPTION_ID], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - deferred = true - ) - ] -) -data class FeedLastUpdatedEntity( - @PrimaryKey - @ColumnInfo(name = SUBSCRIPTION_ID) - var subscriptionId: Long, - - @ColumnInfo(name = LAST_UPDATED) - var lastUpdated: OffsetDateTime? = null -) { - companion object { - const val FEED_LAST_UPDATED_TABLE = "feed_last_updated" - - const val SUBSCRIPTION_ID = "subscription_id" - const val LAST_UPDATED = "last_updated" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt deleted file mode 100644 index ddcb00489..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2021 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.history.dao - -import androidx.room.Dao -import androidx.room.Query -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.history.model.SearchHistoryEntry - -@Dao -interface SearchHistoryDAO : BasicDAO { - - @get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)") - val latestEntry: SearchHistoryEntry? - - @Query("DELETE FROM search_history") - override fun deleteAll(): Int - - @Query("DELETE FROM search_history WHERE search = :query") - fun deleteAllWhereQuery(query: String): Int - - @Query("SELECT * FROM search_history ORDER BY creation_date DESC") - override fun getAll(): Flowable> - - @Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit") - fun getUniqueEntries(limit: Int): Flowable> - - @Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC") - override fun listByService(serviceId: Int): Flowable> - - @Query( - """ - SELECT search FROM search_history WHERE search LIKE :query || - '%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit - """ - ) - fun getSimilarEntries(query: String, limit: Int): Flowable> -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt deleted file mode 100644 index 916d4e5ed..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.history.dao - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.history.model.StreamHistoryEntity -import org.schabi.newpipe.database.history.model.StreamHistoryEntry -import org.schabi.newpipe.database.stream.StreamStatisticsEntry - -@Dao -abstract class StreamHistoryDAO : BasicDAO { - - @Query("SELECT * FROM stream_history") - abstract override fun getAll(): Flowable> - - @Query("DELETE FROM stream_history") - abstract override fun deleteAll(): Int - - override fun listByService(serviceId: Int): Flowable> { - throw UnsupportedOperationException() - } - - @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC") - abstract val history: Flowable> - - @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC") - abstract val historySortedById: Flowable> - - @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1") - abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity? - - @Query("DELETE FROM stream_history WHERE stream_id = :streamId") - abstract fun deleteStreamHistory(streamId: Long): Int - - // Select the latest entry and watch count for each stream id on history table - @RewriteQueriesToDropUnusedColumns - @Query( - """ - SELECT * FROM streams - - INNER JOIN ( - SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount - FROM stream_history - GROUP BY stream_id - ) - ON uid = stream_id - - LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) - ON uid = stream_id_alias - """ - ) - abstract fun getStatistics(): Flowable> -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt deleted file mode 100644 index eee213453..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.history.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index -import androidx.room.PrimaryKey -import java.time.OffsetDateTime - -@Entity( - tableName = SearchHistoryEntry.TABLE_NAME, - indices = [Index(value = [SearchHistoryEntry.SEARCH])] -) -data class SearchHistoryEntry @JvmOverloads constructor( - @ColumnInfo(name = CREATION_DATE) - var creationDate: OffsetDateTime?, - - @ColumnInfo(name = SERVICE_ID) - val serviceId: Int, - - @ColumnInfo(name = SEARCH) - val search: String?, - - @ColumnInfo(name = ID) - @PrimaryKey(autoGenerate = true) - val id: Long = 0 -) { - - @Ignore - fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean { - return serviceId == otherEntry.serviceId && search == otherEntry.search - } - - companion object { - const val ID = "id" - const val TABLE_NAME = "search_history" - const val SERVICE_ID = "service_id" - const val CREATION_DATE = "creation_date" - const val SEARCH = "search" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt deleted file mode 100644 index deba7dd3a..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.history.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.ForeignKey.Companion.CASCADE -import androidx.room.Index -import java.time.OffsetDateTime -import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID -import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE -import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID - -/** - * @param streamUid the stream id this history item will refer to - * @param accessDate the last time the stream was accessed - * @param repeatCount the total number of views this stream received - */ -@Entity( - tableName = STREAM_HISTORY_TABLE, - primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE], - indices = [Index(value = [JOIN_STREAM_ID])], - foreignKeys = [ - ForeignKey( - entity = StreamEntity::class, - parentColumns = arrayOf(STREAM_ID), - childColumns = arrayOf(JOIN_STREAM_ID), - onDelete = CASCADE, - onUpdate = CASCADE - ) - ] -) -data class StreamHistoryEntity( - @ColumnInfo(name = JOIN_STREAM_ID) - val streamUid: Long, - - @ColumnInfo(name = STREAM_ACCESS_DATE) - var accessDate: OffsetDateTime, - - @ColumnInfo(name = STREAM_REPEAT_COUNT) - var repeatCount: Long -) { - companion object { - const val STREAM_HISTORY_TABLE: String = "stream_history" - const val STREAM_ACCESS_DATE: String = "access_date" - const val JOIN_STREAM_ID: String = "stream_id" - const val STREAM_REPEAT_COUNT: String = "repeat_count" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt deleted file mode 100644 index 816b25c2a..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.schabi.newpipe.database.history.model - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import java.time.OffsetDateTime -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.util.image.ImageStrategy - -data class StreamHistoryEntry( - @Embedded - val streamEntity: StreamEntity, - - @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) - val streamId: Long, - - @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) - val accessDate: OffsetDateTime, - - @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) - val repeatCount: Long -) { - - fun toStreamHistoryEntity(): StreamHistoryEntity { - return StreamHistoryEntity(streamId, accessDate, repeatCount) - } - - fun hasEqualValues(other: StreamHistoryEntry): Boolean { - return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && - accessDate.isEqual(other.accessDate) - } - - fun toStreamInfoItem(): StreamInfoItem = StreamInfoItem( - streamEntity.serviceId, - streamEntity.url, - streamEntity.title, - streamEntity.streamType - ).apply { - duration = streamEntity.duration - uploaderName = streamEntity.uploader - uploaderUrl = streamEntity.uploaderUrl - thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt deleted file mode 100644 index 84972a89e..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist - -import androidx.room.ColumnInfo -import org.schabi.newpipe.database.playlist.model.PlaylistEntity - -/** - * This class adds a field to [PlaylistMetadataEntry] that contains an integer representing - * how many times a specific stream is already contained inside a local playlist. Used to be able - * to grey out playlists which already contain the current stream in the playlist append dialog. - * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates - */ -data class PlaylistDuplicatesEntry( - @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) - override val uid: Long, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) - override val thumbnailUrl: String?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) - override val isThumbnailPermanent: Boolean?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) - override val thumbnailStreamId: Long?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) - override var displayIndex: Long?, - - @ColumnInfo(name = PLAYLIST_STREAM_COUNT) - override val streamCount: Long, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) - override val orderingName: String?, - - @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) - val timesStreamIsContained: Long -) : PlaylistMetadataEntry( - uid = uid, - orderingName = orderingName, - thumbnailUrl = thumbnailUrl, - isThumbnailPermanent = isThumbnailPermanent, - thumbnailStreamId = thumbnailStreamId, - displayIndex = displayIndex, - streamCount = streamCount -) { - companion object { - const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt deleted file mode 100644 index 4f2f79aa0..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist - -import org.schabi.newpipe.database.LocalItem - -interface PlaylistLocalItem : LocalItem { - val orderingName: String? - val displayIndex: Long? - val uid: Long - val thumbnailUrl: String? -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt deleted file mode 100644 index 9b62c1380..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist - -import androidx.room.ColumnInfo -import org.schabi.newpipe.database.LocalItem.LocalItemType -import org.schabi.newpipe.database.playlist.model.PlaylistEntity - -open class PlaylistMetadataEntry( - @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) - override val uid: Long, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) - override val orderingName: String?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) - override val thumbnailUrl: String?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) - override var displayIndex: Long?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) - open val isThumbnailPermanent: Boolean?, - - @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) - open val thumbnailStreamId: Long?, - - @ColumnInfo(name = PLAYLIST_STREAM_COUNT) - open val streamCount: Long -) : PlaylistLocalItem { - - override val localItemType: LocalItemType - get() = LocalItemType.PLAYLIST_LOCAL_ITEM - - companion object { - const val PLAYLIST_STREAM_COUNT: String = "streamCount" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt deleted file mode 100644 index 90fdee2d3..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import org.schabi.newpipe.database.LocalItem -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.util.image.ImageStrategy - -data class PlaylistStreamEntry( - @Embedded - val streamEntity: StreamEntity, - - @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0") - val progressMillis: Long, - - @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) - val streamId: Long, - - @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) - val joinIndex: Int -) : LocalItem { - - override val localItemType: LocalItem.LocalItemType - get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - - @Throws(IllegalArgumentException::class) - fun toStreamInfoItem(): StreamInfoItem { - return StreamInfoItem( - streamEntity.serviceId, - streamEntity.url, - streamEntity.title, - streamEntity.streamType - ).apply { - duration = streamEntity.duration - uploaderName = streamEntity.uploader - uploaderUrl = streamEntity.uploaderUrl - thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt deleted file mode 100644 index 9c2dd89a8..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.dao - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.playlist.model.PlaylistEntity - -@Dao -interface PlaylistDAO : BasicDAO { - - @Query("SELECT * FROM playlists") - override fun getAll(): Flowable> - - @Query("DELETE FROM playlists") - override fun deleteAll(): Int - - override fun listByService(serviceId: Int): Flowable> { - throw UnsupportedOperationException() - } - - @Query("SELECT * FROM playlists WHERE uid = :playlistId") - fun getPlaylist(playlistId: Long): Flowable> - - @Query("DELETE FROM playlists WHERE uid = :playlistId") - fun deletePlaylist(playlistId: Long): Int - - @get:Query("SELECT COUNT(*) FROM playlists") - val count: Flowable - - @Transaction - fun upsertPlaylist(playlist: PlaylistEntity): Long { - if (playlist.uid == -1L) { - // This situation is probably impossible. - return insert(playlist) - } else { - update(playlist) - return playlist.uid - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt deleted file mode 100644 index 36a80bc91..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.dao - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity - -@Dao -interface PlaylistRemoteDAO : BasicDAO { - - @Query("SELECT * FROM remote_playlists") - override fun getAll(): Flowable> - - @Query("DELETE FROM remote_playlists") - override fun deleteAll(): Int - - @Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId") - override fun listByService(serviceId: Int): Flowable> - - @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId") - fun getPlaylist(playlistId: Long): Flowable - - @Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId") - fun getPlaylist(serviceId: Long, url: String?): Flowable> - - @get:Query("SELECT * FROM remote_playlists ORDER BY display_index") - val playlists: Flowable> - - @Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId") - fun getPlaylistIdInternal(serviceId: Long, url: String?): Long? - - @Transaction - fun upsert(playlist: PlaylistRemoteEntity): Long { - val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url) - - if (playlistId == null) { - return insert(playlist) - } else { - playlist.uid = playlistId - update(playlist) - return playlistId - } - } - - @Query("DELETE FROM remote_playlists WHERE uid = :playlistId") - fun deletePlaylist(playlistId: Long): Int -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt deleted file mode 100644 index c6b6e37a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.dao - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry -import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity - -@Dao -interface PlaylistStreamDAO : BasicDAO { - - @Query("SELECT * FROM playlist_stream_join") - override fun getAll(): Flowable> - - @Query("DELETE FROM playlist_stream_join") - override fun deleteAll(): Int - - override fun listByService(serviceId: Int): Flowable> { - throw UnsupportedOperationException() - } - - @Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId") - fun deleteBatch(playlistId: Long) - - @Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId") - fun getMaximumIndexOf(playlistId: Long): Flowable - - @Query( - """ - SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END - FROM streams - - LEFT JOIN playlist_stream_join - ON uid = stream_id - - WHERE playlist_id = :playlistId LIMIT 1 - """ - ) - fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable - - // get ids of streams of the given playlist then merge with the stream metadata - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query( - """ - SELECT * FROM streams - - INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) - ON uid = stream_id - - LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) - ON uid = stream_id_alias - - ORDER BY join_index ASC - """ - ) - fun getOrderedStreamsOf(playlistId: Long): Flowable> - - // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table - // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a - // corresponding value in any rows of the join table. So, if you group by the **playlist_id**, - // only playlists that contain videos are grouped and displayed. Look at #9642 #13055 - - @Transaction - @Query( - """ - SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, - (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, - - COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists - - LEFT JOIN playlist_stream_join - ON playlists.uid = playlist_id - - GROUP BY uid - ORDER BY display_index - """ - ) - fun getPlaylistMetadata(): Flowable> - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query( - """ - SELECT *, MIN(join_index) FROM streams - - INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) - ON uid = stream_id - - LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) - ON uid = stream_id_alias - - GROUP BY uid - ORDER BY MIN(join_index) ASC - """ - ) - fun getStreamsWithoutDuplicates(playlistId: Long): Flowable> - - // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table - // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a - // corresponding value in any rows of the join table. So, if you group by the **playlist_id**, - // only playlists that contain videos are grouped and displayed. Look at #9642 #13055 - - @Transaction - @Query( - """ - SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, - (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, - - COALESCE(COUNT(playlist_id), 0) AS streamCount, - COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists - - LEFT JOIN playlist_stream_join - ON playlists.uid = playlist_id - - LEFT JOIN streams - ON streams.uid = stream_id AND :streamUrl = :streamUrl - - GROUP BY playlists.uid - ORDER BY display_index, name - """ - ) - fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable> -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt deleted file mode 100644 index 1f1862f4f..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry - -@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE) -data class PlaylistEntity @JvmOverloads constructor( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = PLAYLIST_ID) - var uid: Long = 0, - - @ColumnInfo(name = PLAYLIST_NAME) - var name: String?, - - @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) - var isThumbnailPermanent: Boolean, - - @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) - var thumbnailStreamId: Long, - - @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) - var displayIndex: Long -) { - - @Ignore - constructor(item: PlaylistMetadataEntry) : this( - uid = item.uid, - name = item.orderingName, - isThumbnailPermanent = item.isThumbnailPermanent!!, - thumbnailStreamId = item.thumbnailStreamId!!, - displayIndex = item.displayIndex!! - ) - - companion object { - const val DEFAULT_THUMBNAIL_ID = -1L - - const val PLAYLIST_TABLE = "playlists" - const val PLAYLIST_ID = "uid" - const val PLAYLIST_NAME = "name" - const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url" - const val PLAYLIST_DISPLAY_INDEX = "display_index" - const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent" - const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt deleted file mode 100644 index 254fa425a..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.model - -import android.text.TextUtils -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index -import androidx.room.PrimaryKey -import org.schabi.newpipe.database.LocalItem.LocalItemType -import org.schabi.newpipe.database.playlist.PlaylistLocalItem -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL -import org.schabi.newpipe.extractor.playlist.PlaylistInfo -import org.schabi.newpipe.util.NO_SERVICE_ID -import org.schabi.newpipe.util.image.ImageStrategy - -@Entity( - tableName = REMOTE_PLAYLIST_TABLE, - indices = [ - Index( - value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL], - unique = true - ) - ] -) -data class PlaylistRemoteEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = REMOTE_PLAYLIST_ID) - override var uid: Long = 0, - - @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) - val serviceId: Int = NO_SERVICE_ID, - - @ColumnInfo(name = REMOTE_PLAYLIST_NAME) - override val orderingName: String?, - - @ColumnInfo(name = REMOTE_PLAYLIST_URL) - val url: String?, - - @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) - override val thumbnailUrl: String?, - - @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) - val uploader: String?, - - @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) - override var displayIndex: Long = -1, // Make sure the new item is on the top - - @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) - val streamCount: Long? -) : PlaylistLocalItem { - - constructor(playlistInfo: PlaylistInfo) : this( - serviceId = playlistInfo.serviceId, - orderingName = playlistInfo.name, - url = playlistInfo.url, - thumbnailUrl = ImageStrategy.imageListToDbUrl( - playlistInfo.thumbnails.ifEmpty { playlistInfo.uploaderAvatars } - ), - uploader = playlistInfo.uploaderName, - streamCount = playlistInfo.streamCount - ) - - override val localItemType: LocalItemType - get() = LocalItemType.PLAYLIST_REMOTE_ITEM - - /** - * Returns boolean comparing the online playlist and the local copy. - * (False if info changed such as playlist name or track count) - */ - @Ignore - fun isIdenticalTo(info: PlaylistInfo): Boolean { - return this.serviceId == info.serviceId && this.streamCount == info.streamCount && - TextUtils.equals(this.orderingName, info.name) && - TextUtils.equals(this.url, info.url) && - // we want to update the local playlist data even when either the remote thumbnail - // URL changes, or the preferred image quality setting is changed by the user - TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) && - TextUtils.equals(this.uploader, info.uploaderName) - } - - companion object { - const val REMOTE_PLAYLIST_TABLE = "remote_playlists" - const val REMOTE_PLAYLIST_ID = "uid" - const val REMOTE_PLAYLIST_SERVICE_ID = "service_id" - const val REMOTE_PLAYLIST_NAME = "name" - const val REMOTE_PLAYLIST_URL = "url" - const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url" - const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader" - const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index" - const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt deleted file mode 100644 index 6ab1b6ac4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.playlist.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.ForeignKey.Companion.CASCADE -import androidx.room.Index -import org.schabi.newpipe.database.LocalItem -import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE -import org.schabi.newpipe.database.stream.model.StreamEntity - -@Entity( - tableName = PLAYLIST_STREAM_JOIN_TABLE, - primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX], - indices = [ - Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true), - Index(value = [JOIN_STREAM_ID]) - ], - foreignKeys = [ - ForeignKey( - entity = PlaylistEntity::class, - parentColumns = arrayOf(PLAYLIST_ID), - childColumns = arrayOf(JOIN_PLAYLIST_ID), - onDelete = CASCADE, - onUpdate = CASCADE, - deferred = true - ), - ForeignKey( - entity = StreamEntity::class, - parentColumns = arrayOf(StreamEntity.STREAM_ID), - childColumns = arrayOf(JOIN_STREAM_ID), - onDelete = CASCADE, - onUpdate = CASCADE, - deferred = true - ) - ] -) -data class PlaylistStreamEntity( - @ColumnInfo(name = JOIN_PLAYLIST_ID) - val playlistUid: Long, - - @ColumnInfo(name = JOIN_STREAM_ID) - val streamUid: Long, - - @ColumnInfo(name = JOIN_INDEX) - val index: Int -) : LocalItem { - - override val localItemType: LocalItem.LocalItemType - get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - - companion object { - const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join" - const val JOIN_PLAYLIST_ID = "playlist_id" - const val JOIN_STREAM_ID = "stream_id" - const val JOIN_INDEX = "join_index" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt deleted file mode 100644 index ce74678ca..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.stream - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import androidx.room.Ignore -import java.time.OffsetDateTime -import org.schabi.newpipe.database.LocalItem -import org.schabi.newpipe.database.history.model.StreamHistoryEntity -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.util.image.ImageStrategy - -data class StreamStatisticsEntry( - @Embedded - val streamEntity: StreamEntity, - - @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0") - val progressMillis: Long, - - @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) - val streamId: Long, - - @ColumnInfo(name = STREAM_LATEST_DATE) - val latestAccessDate: OffsetDateTime, - - @ColumnInfo(name = STREAM_WATCH_COUNT) - val watchCount: Long -) : LocalItem { - - override val localItemType: LocalItem.LocalItemType - get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - - @Ignore - fun toStreamInfoItem(): StreamInfoItem { - return StreamInfoItem( - streamEntity.serviceId, - streamEntity.url, - streamEntity.title, - streamEntity.streamType - ).apply { - duration = streamEntity.duration - uploaderName = streamEntity.uploader - uploaderUrl = streamEntity.uploaderUrl - thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - } - } - - companion object { - const val STREAM_LATEST_DATE = "latestAccess" - const val STREAM_WATCH_COUNT = "watchCount" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt deleted file mode 100644 index abeabf888..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.database.stream - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity - -data class StreamWithState( - @Embedded - val stream: StreamEntity, - - @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS) - val stateProgressMillis: Long? -) diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt deleted file mode 100644 index 86ba262f5..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt +++ /dev/null @@ -1,148 +0,0 @@ -package org.schabi.newpipe.database.stream.dao - -import androidx.room.ColumnInfo -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import java.time.OffsetDateTime -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID -import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.util.StreamTypeUtil - -@Dao -abstract class StreamDAO : BasicDAO { - @Query("SELECT * FROM streams") - abstract override fun getAll(): Flowable> - - @Query("DELETE FROM streams") - abstract override fun deleteAll(): Int - - @Query("SELECT * FROM streams WHERE service_id = :serviceId") - abstract override fun listByService(serviceId: Int): Flowable> - - @Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId") - abstract fun getStream(serviceId: Long, url: String): Flowable> - - @Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId") - abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable - - @Insert(onConflict = OnConflictStrategy.IGNORE) - internal abstract fun silentInsertInternal(stream: StreamEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - internal abstract fun silentInsertAllInternal(streams: List): List - - @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId") - internal abstract fun exists(serviceId: Int, url: String): Boolean - - @Query( - """ - SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration - FROM streams WHERE url = :url AND service_id = :serviceId - """ - ) - internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed? - - @Transaction - open fun upsert(newerStream: StreamEntity): Long { - val uid = silentInsertInternal(newerStream) - - if (uid != -1L) { - newerStream.uid = uid - return uid - } - - compareAndUpdateStream(newerStream) - - update(newerStream) - return newerStream.uid - } - - @Transaction - open fun upsertAll(streams: List): List { - val insertUidList = silentInsertAllInternal(streams) - - val streamIds = ArrayList(streams.size) - for ((index, uid) in insertUidList.withIndex()) { - val newerStream = streams[index] - if (uid != -1L) { - streamIds.add(uid) - newerStream.uid = uid - continue - } - - compareAndUpdateStream(newerStream) - streamIds.add(newerStream.uid) - } - - update(streams) - return streamIds - } - - private fun compareAndUpdateStream(newerStream: StreamEntity) { - val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url) - ?: error("Stream cannot be null just after insertion.") - newerStream.uid = existentMinimalStream.uid - - if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) { - // Use the existent upload date if the newer stream does not have a better precision - // (i.e. is an approximation). This is done to prevent unnecessary changes. - val hasBetterPrecision = - newerStream.uploadDate != null && newerStream.isUploadDateApproximation != true - if (existentMinimalStream.uploadDate != null && !hasBetterPrecision) { - newerStream.uploadDate = existentMinimalStream.uploadDate - newerStream.textualUploadDate = existentMinimalStream.textualUploadDate - newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation - } - - if (existentMinimalStream.duration > 0 && newerStream.duration < 0) { - newerStream.duration = existentMinimalStream.duration - } - } - } - - @Query( - """ - DELETE FROM streams WHERE - - NOT EXISTS (SELECT 1 FROM stream_history sh - WHERE sh.stream_id = streams.uid) - - AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps - WHERE ps.stream_id = streams.uid) - - AND NOT EXISTS (SELECT 1 FROM feed f - WHERE f.stream_id = streams.uid) - """ - ) - abstract fun deleteOrphans(): Int - - /** - * Minimal entry class used when comparing/updating an existent stream. - */ - internal data class StreamCompareFeed( - @ColumnInfo(name = STREAM_ID) - var uid: Long = 0, - - @ColumnInfo(name = StreamEntity.STREAM_TYPE) - var streamType: StreamType, - - @ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE) - var textualUploadDate: String? = null, - - @ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE) - var uploadDate: OffsetDateTime? = null, - - @ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION) - var isUploadDateApproximation: Boolean? = null, - - @ColumnInfo(name = StreamEntity.STREAM_DURATION) - var duration: Long - ) -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt deleted file mode 100644 index f3c44f1f2..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2021 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.stream.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Flowable -import org.schabi.newpipe.database.BasicDAO -import org.schabi.newpipe.database.stream.model.StreamStateEntity - -@Dao -interface StreamStateDAO : BasicDAO { - - @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE) - override fun getAll(): Flowable> - - @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE) - override fun deleteAll(): Int - - override fun listByService(serviceId: Int): Flowable> { - throw UnsupportedOperationException() - } - - @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") - fun getState(streamId: Long): Flowable> - - @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") - fun deleteState(streamId: Long): Int - - @Insert(onConflict = OnConflictStrategy.Companion.IGNORE) - fun silentInsertInternal(streamState: StreamStateEntity) - - @Transaction - fun upsert(stream: StreamStateEntity): Long { - silentInsertInternal(stream) - return update(stream).toLong() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt deleted file mode 100644 index 067f666b6..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.schabi.newpipe.database.stream.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index -import androidx.room.PrimaryKey -import java.io.Serializable -import java.time.OffsetDateTime -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL -import org.schabi.newpipe.extractor.localization.DateWrapper -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.player.playqueue.PlayQueueItem -import org.schabi.newpipe.util.image.ImageStrategy - -@Entity( - tableName = STREAM_TABLE, - indices = [ - Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true) - ] -) -data class StreamEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = STREAM_ID) - var uid: Long = 0, - - @ColumnInfo(name = STREAM_SERVICE_ID) - var serviceId: Int, - - @ColumnInfo(name = STREAM_URL) - var url: String, - - @ColumnInfo(name = STREAM_TITLE) - var title: String, - - @ColumnInfo(name = STREAM_TYPE) - var streamType: StreamType, - - @ColumnInfo(name = STREAM_DURATION) - var duration: Long, - - @ColumnInfo(name = STREAM_UPLOADER) - var uploader: String, - - @ColumnInfo(name = STREAM_UPLOADER_URL) - var uploaderUrl: String? = null, - - @ColumnInfo(name = STREAM_THUMBNAIL_URL) - var thumbnailUrl: String? = null, - - @ColumnInfo(name = STREAM_VIEWS) - var viewCount: Long? = null, - - @ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE) - var textualUploadDate: String? = null, - - @ColumnInfo(name = STREAM_UPLOAD_DATE) - var uploadDate: OffsetDateTime? = null, - - @ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION) - var isUploadDateApproximation: Boolean? = null -) : Serializable { - @Ignore - constructor(item: StreamInfoItem) : this( - serviceId = item.serviceId, url = item.url, title = item.name, - streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, - uploaderUrl = item.uploaderUrl, - thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount, - textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(), - isUploadDateApproximation = item.uploadDate?.isApproximation - ) - - @Ignore - constructor(info: StreamInfo) : this( - serviceId = info.serviceId, url = info.url, title = info.name, - streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, - uploaderUrl = info.uploaderUrl, - thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount, - textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(), - isUploadDateApproximation = info.uploadDate?.isApproximation - ) - - @Ignore - constructor(item: PlayQueueItem) : this( - serviceId = item.serviceId, - url = item.url, - title = item.title, - streamType = item.streamType, - duration = item.duration, - uploader = item.uploader, - uploaderUrl = item.uploaderUrl, - thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails) - ) - - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(serviceId, url, title, streamType) - item.duration = duration - item.uploaderName = uploader - item.uploaderUrl = uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl) - - if (viewCount != null) item.viewCount = viewCount as Long - item.textualUploadDate = textualUploadDate - item.uploadDate = uploadDate?.let { - DateWrapper(it, isUploadDateApproximation ?: false) - } - - return item - } - - companion object { - const val STREAM_TABLE = "streams" - const val STREAM_ID = "uid" - const val STREAM_SERVICE_ID = "service_id" - const val STREAM_URL = "url" - const val STREAM_TITLE = "title" - const val STREAM_TYPE = "stream_type" - const val STREAM_DURATION = "duration" - const val STREAM_UPLOADER = "uploader" - const val STREAM_UPLOADER_URL = "uploader_url" - const val STREAM_THUMBNAIL_URL = "thumbnail_url" - - const val STREAM_VIEWS = "view_count" - const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date" - const val STREAM_UPLOAD_DATE = "upload_date" - const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt deleted file mode 100644 index 759a2dcec..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.stream.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.ForeignKey.Companion.CASCADE -import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID -import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID -import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS -import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE - -@Entity( - tableName = STREAM_STATE_TABLE, - primaryKeys = [JOIN_STREAM_ID], - foreignKeys = [ - ForeignKey( - entity = StreamEntity::class, - parentColumns = arrayOf(STREAM_ID), - childColumns = arrayOf(JOIN_STREAM_ID), - onDelete = CASCADE, - onUpdate = CASCADE - ) - ] -) -data class StreamStateEntity( - @ColumnInfo(name = JOIN_STREAM_ID) - val streamUid: Long, - - @ColumnInfo(name = STREAM_PROGRESS_MILLIS) - val progressMillis: Long -) { - /** - * The state will be considered valid, and thus be saved, if the progress is more than - * [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length. - * @param durationInSeconds the duration of the stream connected with this state, in seconds - * @return whether this stream state entity should be saved or not - */ - fun isValid(durationInSeconds: Long): Boolean { - return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS || - progressMillis > durationInSeconds * 1000 / 4 - } - - /** - * The video will be considered as finished, if the time left is less than - * [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length. - * The state will be saved anyway, so that it can be shown under stream info items, but the - * player will not resume if a state is considered as finished. Finished streams are also the - * ones that can be filtered out in the feed fragment. - * @param durationInSeconds the duration of the stream connected with this state, in seconds - * @return whether the stream is finished or not - */ - fun isFinished(durationInSeconds: Long): Boolean { - return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS && - progressMillis >= durationInSeconds * 1000 * 3 / 4 - } - - companion object { - const val STREAM_STATE_TABLE = "stream_state" - const val JOIN_STREAM_ID = "stream_id" - - // This additional field is required for the SQL query because 'stream_id' is used - // for some other joins already - const val JOIN_STREAM_ID_ALIAS = "stream_id_alias" - const val STREAM_PROGRESS_MILLIS = "progress_time" - - /** - * Playback state will not be saved, if playback time is less than this threshold - * (5000ms = 5s). - */ - const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L - - /** - * Stream will be considered finished if the playback time left exceeds this threshold - * (60000ms = 60s). - * @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished - */ - const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt deleted file mode 100644 index f9bb18c0c..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.subscription - -import androidx.annotation.IntDef - -@IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED) -@Retention(AnnotationRetention.SOURCE) -annotation class NotificationMode { - companion object { - const val DISABLED = 0 - const val ENABLED = 1 // other values reserved for the future - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt deleted file mode 100644 index 72bdbcf5c..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.schabi.newpipe.database.subscription - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Maybe -import org.schabi.newpipe.database.BasicDAO - -@Dao -abstract class SubscriptionDAO : BasicDAO { - @Query("SELECT COUNT(*) FROM subscriptions") - abstract fun rowCount(): Flowable - - @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId") - abstract override fun listByService(serviceId: Int): Flowable> - - @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") - abstract override fun getAll(): Flowable> - - @Query( - """ - SELECT * FROM subscriptions - - WHERE name LIKE '%' || :filter || '%' - - ORDER BY name COLLATE NOCASE ASC - """ - ) - abstract fun getSubscriptionsFiltered(filter: String): Flowable> - - @RewriteQueriesToDropUnusedColumns - @Query( - """ - SELECT * FROM subscriptions s - - LEFT JOIN feed_group_subscription_join fgs - ON s.uid = fgs.subscription_id - - WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) - - ORDER BY name COLLATE NOCASE ASC - """ - ) - abstract fun getSubscriptionsOnlyUngrouped( - currentGroupId: Long - ): Flowable> - - @RewriteQueriesToDropUnusedColumns - @Query( - """ - SELECT * FROM subscriptions s - - LEFT JOIN feed_group_subscription_join fgs - ON s.uid = fgs.subscription_id - - WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) - AND s.name LIKE '%' || :filter || '%' - - ORDER BY name COLLATE NOCASE ASC - """ - ) - abstract fun getSubscriptionsOnlyUngroupedFiltered( - currentGroupId: Long, - filter: String - ): Flowable> - - @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") - abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable> - - @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") - abstract fun getSubscription(serviceId: Int, url: String): Maybe - - @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId") - abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity - - @Query("DELETE FROM subscriptions") - abstract override fun deleteAll(): Int - - @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") - abstract fun deleteSubscription(serviceId: Int, url: String): Int - - @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") - internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long? - - @Insert(onConflict = OnConflictStrategy.IGNORE) - internal abstract fun silentInsertAllInternal(entities: List): List - - @Transaction - open fun upsertAll(entities: List): List { - val insertUidList = silentInsertAllInternal(entities) - - insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> - val entity = entities[index] - - if (uidFromInsert != -1L) { - entity.uid = uidFromInsert - } else { - val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!) - ?: error("Subscription cannot be null just after insertion.") - entity.uid = subscriptionIdFromDb - - update(entity) - } - } - - return entities - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt deleted file mode 100644 index 7df9830e4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.database.subscription - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index -import androidx.room.PrimaryKey -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.util.NO_SERVICE_ID -import org.schabi.newpipe.util.image.ImageStrategy - -@Entity( - tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE, - indices = [ - Index( - value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL], - unique = true - ) - ] -) -data class SubscriptionEntity( - @PrimaryKey(autoGenerate = true) - var uid: Long = 0, - - @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) - var serviceId: Int = NO_SERVICE_ID, - - @ColumnInfo(name = SUBSCRIPTION_URL) - var url: String? = null, - - @ColumnInfo(name = SUBSCRIPTION_NAME) - var name: String? = null, - - @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) - var avatarUrl: String? = null, - - @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) - var subscriberCount: Long? = null, - - @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) - var description: String? = null, - - @get:NotificationMode - @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) - var notificationMode: Int = 0 -) { - @Ignore - fun toChannelInfoItem(): ChannelInfoItem { - return ChannelInfoItem(this.serviceId, this.url, this.name).apply { - thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl) - subscriberCount = this@SubscriptionEntity.subscriberCount ?: -1 - description = this@SubscriptionEntity.description - } - } - - companion object { - const val SUBSCRIPTION_UID: String = "uid" - const val SUBSCRIPTION_TABLE: String = "subscriptions" - const val SUBSCRIPTION_SERVICE_ID: String = "service_id" - const val SUBSCRIPTION_URL: String = "url" - const val SUBSCRIPTION_NAME: String = "name" - const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url" - const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count" - const val SUBSCRIPTION_DESCRIPTION: String = "description" - const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode" - - @JvmStatic - @Ignore - fun from(info: ChannelInfo): SubscriptionEntity { - return SubscriptionEntity( - serviceId = info.serviceId, - url = info.url, - name = info.name, - avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars), - description = info.description, - subscriberCount = info.subscriberCount - ) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java deleted file mode 100644 index 33702a6a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe.download; - -import android.content.Intent; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.ViewTreeObserver; - -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.FragmentTransaction; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ActivityDownloaderBinding; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.FocusOverlayView; - -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.ui.fragment.MissionsFragment; - -public class DownloadActivity extends AppCompatActivity { - - private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag"; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - // Service - final Intent i = new Intent(); - i.setClass(this, DownloadManagerService.class); - startService(i); - - ThemeHelper.setTheme(this); - - super.onCreate(savedInstanceState); - - final ActivityDownloaderBinding downloaderBinding = - ActivityDownloaderBinding.inflate(getLayoutInflater()); - setContentView(downloaderBinding.getRoot()); - - setSupportActionBar(downloaderBinding.toolbarLayout.toolbar); - - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setTitle(R.string.downloads_title); - actionBar.setDisplayShowTitleEnabled(true); - } - - getWindow().getDecorView().getViewTreeObserver() - .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - updateFragments(); - getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this); - } - }); - - if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(this); - } - } - - private void updateFragments() { - final MissionsFragment fragment = new MissionsFragment(); - - getSupportFragmentManager().beginTransaction() - .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .commit(); - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - super.onCreateOptionsMenu(menu); - final MenuInflater inflater = getMenuInflater(); - - inflater.inflate(R.menu.download_menu, menu); - - return true; - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java deleted file mode 100644 index 178fcefe1..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ /dev/null @@ -1,1127 +0,0 @@ -package org.schabi.newpipe.download; - -import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; -import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; - -import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.os.IBinder; -import android.provider.Settings; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.RadioGroup; -import android.widget.SeekBar; -import android.widget.Toast; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.Toolbar; -import androidx.collection.SparseArrayCompat; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.DialogFragment; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.livefront.bridge.Bridge; -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.DownloadDialogBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; -import org.schabi.newpipe.streams.io.StoredDirectoryHelper; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.AudioTrackAdapter; -import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; -import org.schabi.newpipe.util.FilePickerActivityHelper; -import org.schabi.newpipe.util.FilenameUtils; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.SecondaryStreamHelper; -import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; -import org.schabi.newpipe.util.StreamItemAdapter; -import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; -import org.schabi.newpipe.util.ThemeHelper; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.postprocessing.Postprocessing; -import us.shandian.giga.service.DownloadManager; -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; -import us.shandian.giga.service.MissionState; - -public class DownloadDialog extends DialogFragment - implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { - private static final String TAG = "DialogFragment"; - private static final boolean DEBUG = MainActivity.DEBUG; - - @State - StreamInfo currentInfo; - @State - StreamInfoWrapper wrappedVideoStreams; - @State - StreamInfoWrapper wrappedSubtitleStreams; - @State - AudioTracksWrapper wrappedAudioTracks; - @State - int selectedAudioTrackIndex; - @State - int selectedVideoIndex; // set in the constructor - @State - int selectedAudioIndex = 0; // default to the first item - @State - int selectedSubtitleIndex = 0; // default to the first item - - private StoredDirectoryHelper mainStorageAudio = null; - private StoredDirectoryHelper mainStorageVideo = null; - private DownloadManager downloadManager = null; - private MenuItem okButton = null; - private Context context = null; - private boolean askForSavePath; - - private AudioTrackAdapter audioTrackAdapter; - private StreamItemAdapter audioStreamsAdapter; - private StreamItemAdapter videoStreamsAdapter; - private StreamItemAdapter subtitleStreamsAdapter; - - private final CompositeDisposable disposables = new CompositeDisposable(); - - private DownloadDialogBinding dialogBinding; - - private SharedPreferences prefs; - - // Variables for file name and MIME type when picking new folder because it's not set yet - private String filenameTmp; - private String mimeTmp; - - private final ActivityResultLauncher requestDownloadSaveAsLauncher = - registerForActivityResult( - new StartActivityForResult(), this::requestDownloadSaveAsResult); - private final ActivityResultLauncher requestDownloadPickAudioFolderLauncher = - registerForActivityResult( - new StartActivityForResult(), this::requestDownloadPickAudioFolderResult); - private final ActivityResultLauncher requestDownloadPickVideoFolderLauncher = - registerForActivityResult( - new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); - - /*////////////////////////////////////////////////////////////////////////// - // Instance creation - //////////////////////////////////////////////////////////////////////////*/ - - public DownloadDialog() { - // Just an empty default no-arg ctor to keep Fragment.instantiate() happy - // otherwise InstantiationException will be thrown when fragment is recreated - // TODO: Maybe use a custom FragmentFactory instead? - } - - /** - * Create a new download dialog with the video, audio and subtitle streams from the provided - * stream info. Video streams and video-only streams will be put into a single list menu, - * sorted according to their resolution and the default video resolution will be selected. - * - * @param context the context to use just to obtain preferences and strings (will not be stored) - * @param info the info from which to obtain downloadable streams and other info (e.g. title) - */ - public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) { - this.currentInfo = info; - - final List audioStreams = - getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP); - final List> groupedAudioStreams = - ListHelper.getGroupedAudioStreams(context, audioStreams); - this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context); - this.selectedAudioTrackIndex = - ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams); - - // TODO: Adapt this code when the downloader support other types of stream deliveries - final List videoStreams = ListHelper.getSortedStreamVideosList( - context, - getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP), - getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP), - false, - // If there are multiple languages available, prefer streams without audio - // to allow language selection - wrappedAudioTracks.size() > 1 - ); - - this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context); - this.wrappedSubtitleStreams = new StreamInfoWrapper<>( - getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); - - this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Android lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (DEBUG) { - Log.d(TAG, "onCreate() called with: " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - - if (!PermissionHelper.checkStoragePermissions(getActivity(), - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - dismiss(); - return; - } - - // context will remain null if dismiss() was called above, allowing to check whether the - // dialog is being dismissed in onViewCreated() - context = getContext(); - - setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); - Bridge.restoreInstanceState(this, savedInstanceState); - - this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks); - this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); - updateSecondaryStreams(); - - final Intent intent = new Intent(context, DownloadManagerService.class); - context.startService(intent); - - context.bindService(intent, new ServiceConnection() { - @Override - public void onServiceConnected(final ComponentName cname, final IBinder service) { - final DownloadManagerBinder mgr = (DownloadManagerBinder) service; - - mainStorageAudio = mgr.getMainStorageAudio(); - mainStorageVideo = mgr.getMainStorageVideo(); - downloadManager = mgr.getDownloadManager(); - askForSavePath = mgr.askForSavePath(); - - okButton.setEnabled(true); - - context.unbindService(this); - } - - @Override - public void onServiceDisconnected(final ComponentName name) { - // nothing to do - } - }, Context.BIND_AUTO_CREATE); - } - - /** - * Update the displayed video streams based on the selected audio track. - */ - private void updateSecondaryStreams() { - final StreamInfoWrapper audioStreams = getWrappedAudioStreams(); - final var secondaryStreams = new SparseArrayCompat>(4); - final List videoStreams = wrappedVideoStreams.getStreamsList(); - wrappedVideoStreams.resetInfo(); - - for (int i = 0; i < videoStreams.size(); i++) { - if (!videoStreams.get(i).isVideoOnly()) { - continue; - } - final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor( - context, audioStreams.getStreamsList(), videoStreams.get(i)); - - if (audioStream != null) { - secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream)); - } else if (DEBUG) { - final MediaFormat mediaFormat = videoStreams.get(i).getFormat(); - if (mediaFormat != null) { - Log.w(TAG, "No audio stream candidates for video format " - + mediaFormat.name()); - } else { - Log.w(TAG, "No audio stream candidates for unknown video format"); - } - } - } - - this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams); - this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - final ViewGroup container, - final Bundle savedInstanceState) { - if (DEBUG) { - Log.d(TAG, "onCreateView() called with: " - + "inflater = [" + inflater + "], container = [" + container + "], " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - return inflater.inflate(R.layout.download_dialog, container); - } - - @Override - public void onViewCreated(@NonNull final View view, - @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - dialogBinding = DownloadDialogBinding.bind(view); - if (context == null) { - return; // the dialog is being dismissed, see the call to dismiss() in onCreate() - } - - dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), - currentInfo.getName())); - selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(), - getWrappedAudioStreams().getStreamsList()); - - selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); - - dialogBinding.qualitySpinner.setOnItemSelectedListener(this); - dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this); - dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this); - dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this); - - initToolbar(dialogBinding.toolbarLayout.toolbar); - setupDownloadOptions(); - - prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - final int threads = prefs.getInt(getString(R.string.default_download_threads), 3); - dialogBinding.threadsCount.setText(String.valueOf(threads)); - dialogBinding.threads.setProgress(threads - 1); - dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { - @Override - public void onProgressChanged(@NonNull final SeekBar seekbar, - final int progress, - final boolean fromUser) { - final int newProgress = progress + 1; - prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) - .apply(); - dialogBinding.threadsCount.setText(String.valueOf(newProgress)); - } - }); - - fetchStreamsSize(); - } - - private void initToolbar(final Toolbar toolbar) { - if (DEBUG) { - Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); - } - - toolbar.setTitle(R.string.download_dialog_title); - toolbar.setNavigationIcon(R.drawable.ic_arrow_back); - toolbar.inflateMenu(R.menu.dialog_url); - toolbar.setNavigationOnClickListener(v -> dismiss()); - toolbar.setNavigationContentDescription(R.string.cancel); - - okButton = toolbar.getMenu().findItem(R.id.okay); - okButton.setEnabled(false); // disable until the download service connection is done - - toolbar.setOnMenuItemClickListener(item -> { - if (item.getItemId() == R.id.okay) { - prepareSelectedDownload(); - return true; - } - return false; - }); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposables.clear(); - } - - @Override - public void onDestroyView() { - dialogBinding = null; - super.onDestroyView(); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Video, audio and subtitle spinners - //////////////////////////////////////////////////////////////////////////*/ - - private void fetchStreamsSize() { - disposables.clear(); - disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() - == R.id.video_button) { - setupVideoSpinner(); - } - }, throwable -> ErrorUtil.showSnackbar(context, - new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, - "Downloading video stream size", currentInfo)))); - disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() - == R.id.audio_button) { - setupAudioSpinner(); - } - }, throwable -> ErrorUtil.showSnackbar(context, - new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, - "Downloading audio stream size", currentInfo)))); - disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() - == R.id.subtitle_button) { - setupSubtitleSpinner(); - } - }, throwable -> ErrorUtil.showSnackbar(context, - new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, - "Downloading subtitle stream size", currentInfo)))); - } - - private void setupAudioTrackSpinner() { - if (getContext() == null) { - return; - } - - dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter); - dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex); - } - - private void setupAudioSpinner() { - if (getContext() == null) { - return; - } - - dialogBinding.qualitySpinner.setVisibility(View.GONE); - setRadioButtonsState(true); - dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter); - dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex); - dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE); - dialogBinding.audioTrackSpinner.setVisibility( - wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); - dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); - } - - private void setupVideoSpinner() { - if (getContext() == null) { - return; - } - - dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter); - dialogBinding.qualitySpinner.setSelection(selectedVideoIndex); - dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); - setRadioButtonsState(true); - dialogBinding.audioStreamSpinner.setVisibility(View.GONE); - onVideoStreamSelected(); - } - - private void onVideoStreamSelected() { - final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly(); - - dialogBinding.audioTrackSpinner.setVisibility( - isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); - dialogBinding.audioTrackPresentInVideoText.setVisibility( - !isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); - } - - private void setupSubtitleSpinner() { - if (getContext() == null) { - return; - } - - dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter); - dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex); - dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); - setRadioButtonsState(true); - dialogBinding.audioStreamSpinner.setVisibility(View.GONE); - dialogBinding.audioTrackSpinner.setVisibility(View.GONE); - dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Activity results - //////////////////////////////////////////////////////////////////////////*/ - - private void requestDownloadPickAudioFolderResult(final ActivityResult result) { - requestDownloadPickFolderResult( - result, getString(R.string.download_path_audio_key), DownloadManager.TAG_AUDIO); - } - - private void requestDownloadPickVideoFolderResult(final ActivityResult result) { - requestDownloadPickFolderResult( - result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); - } - - private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) { - if (result.getResultCode() != Activity.RESULT_OK) { - return; - } - - if (result.getData() == null || result.getData().getData() == null) { - showFailedDialog(R.string.general_error); - return; - } - - if (FilePickerActivityHelper.isOwnFileUri(context, result.getData().getData())) { - final File file = Utils.getFileForUri(result.getData().getData()); - checkSelectedDownload(null, Uri.fromFile(file), file.getName(), - StoredFileHelper.DEFAULT_MIME); - return; - } - - final DocumentFile docFile = DocumentFile.fromSingleUri(context, - result.getData().getData()); - if (docFile == null) { - showFailedDialog(R.string.general_error); - return; - } - - // check if the selected file was previously used - checkSelectedDownload(null, result.getData().getData(), docFile.getName(), - docFile.getType()); - } - - private void requestDownloadPickFolderResult(@NonNull final ActivityResult result, - final String key, - final String tag) { - if (result.getResultCode() != Activity.RESULT_OK) { - return; - } - - if (result.getData() == null || result.getData().getData() == null) { - showFailedDialog(R.string.general_error); - return; - } - - Uri uri = result.getData().getData(); - if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { - uri = Uri.fromFile(Utils.getFileForUri(uri)); - } else { - context.grantUriPermission(context.getPackageName(), uri, - StoredDirectoryHelper.PERMISSION_FLAGS); - } - - PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, - uri.toString()).apply(); - - try { - final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag); - checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), - filenameTmp, mimeTmp); - } catch (final IOException e) { - showFailedDialog(R.string.general_error); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Listeners - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) { - if (DEBUG) { - Log.d(TAG, "onCheckedChanged() called with: " - + "group = [" + group + "], checkedId = [" + checkedId + "]"); - } - boolean flag = true; - - if (checkedId == R.id.audio_button) { - setupAudioSpinner(); - } else if (checkedId == R.id.video_button) { - setupVideoSpinner(); - } else if (checkedId == R.id.subtitle_button) { - setupSubtitleSpinner(); - flag = false; - } - - dialogBinding.threads.setEnabled(flag); - } - - @Override - public void onItemSelected(final AdapterView parent, - final View view, - final int position, - final long id) { - if (DEBUG) { - Log.d(TAG, "onItemSelected() called with: " - + "parent = [" + parent + "], view = [" + view + "], " - + "position = [" + position + "], id = [" + id + "]"); - } - - final int parentId = parent.getId(); - if (parentId == R.id.quality_spinner) { - final int checkedRadioButtonId = dialogBinding.videoAudioGroup - .getCheckedRadioButtonId(); - if (checkedRadioButtonId == R.id.video_button) { - selectedVideoIndex = position; - onVideoStreamSelected(); - } else if (checkedRadioButtonId == R.id.subtitle_button) { - selectedSubtitleIndex = position; - } - onItemSelectedSetFileName(); - } else if (parentId == R.id.audio_track_spinner) { - final boolean trackChanged = selectedAudioTrackIndex != position; - selectedAudioTrackIndex = position; - if (trackChanged) { - updateSecondaryStreams(); - fetchStreamsSize(); - } - } else if (parentId == R.id.audio_stream_spinner) { - selectedAudioIndex = position; - } - } - - private void onItemSelectedSetFileName() { - final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName()); - final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText()) - .map(Object::toString) - .orElse(""); - - if (prevFileName.isEmpty() - || prevFileName.equals(fileName) - || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) { - // only update the file name field if it was not edited by the user - - final int radioButtonId = dialogBinding.videoAudioGroup - .getCheckedRadioButtonId(); - if (radioButtonId == R.id.audio_button || radioButtonId == R.id.video_button) { - if (!prevFileName.equals(fileName)) { - // since the user might have switched between audio and video, the correct - // text might already be in place, so avoid resetting the cursor position - dialogBinding.fileName.setText(fileName); - } - } else if (radioButtonId == R.id.subtitle_button) { - final String setSubtitleLanguageCode = subtitleStreamsAdapter - .getItem(selectedSubtitleIndex).getLanguageTag(); - // this will reset the cursor position, which is bad UX, but it can't be avoided - dialogBinding.fileName.setText(getString( - R.string.caption_file_name, fileName, setSubtitleLanguageCode)); - } - } - } - - @Override - public void onNothingSelected(final AdapterView parent) { - } - - - /*////////////////////////////////////////////////////////////////////////// - // Download - //////////////////////////////////////////////////////////////////////////*/ - - protected void setupDownloadOptions() { - setRadioButtonsState(false); - setupAudioTrackSpinner(); - - final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; - final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; - final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; - - dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE - : View.GONE); - dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE - : View.GONE); - dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable - ? View.VISIBLE : View.GONE); - - prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); - final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), - getString(R.string.last_download_type_video_key)); - - if (isVideoStreamsAvailable - && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { - dialogBinding.videoButton.setChecked(true); - setupVideoSpinner(); - } else if (isAudioStreamsAvailable - && (defaultMedia.equals(getString(R.string.last_download_type_audio_key)))) { - dialogBinding.audioButton.setChecked(true); - setupAudioSpinner(); - } else if (isSubtitleStreamsAvailable - && (defaultMedia.equals(getString(R.string.last_download_type_subtitle_key)))) { - dialogBinding.subtitleButton.setChecked(true); - setupSubtitleSpinner(); - } else if (isVideoStreamsAvailable) { - dialogBinding.videoButton.setChecked(true); - setupVideoSpinner(); - } else if (isAudioStreamsAvailable) { - dialogBinding.audioButton.setChecked(true); - setupAudioSpinner(); - } else if (isSubtitleStreamsAvailable) { - dialogBinding.subtitleButton.setChecked(true); - setupSubtitleSpinner(); - } else { - Toast.makeText(getContext(), R.string.no_streams_available_download, - Toast.LENGTH_SHORT).show(); - dismiss(); - } - } - - private void setRadioButtonsState(final boolean enabled) { - dialogBinding.audioButton.setEnabled(enabled); - dialogBinding.videoButton.setEnabled(enabled); - dialogBinding.subtitleButton.setEnabled(enabled); - } - - private StreamInfoWrapper getWrappedAudioStreams() { - if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { - return StreamInfoWrapper.empty(); - } - return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); - } - - private int getSubtitleIndexBy(@NonNull final List streams) { - final Localization preferredLocalization = NewPipe.getPreferredLocalization(); - - int candidate = 0; - for (int i = 0; i < streams.size(); i++) { - final Locale streamLocale = streams.get(i).getLocale(); - - final boolean languageEquals = streamLocale.getLanguage() != null - && preferredLocalization.getLanguageCode() != null - && streamLocale.getLanguage() - .equals(new Locale(preferredLocalization.getLanguageCode()).getLanguage()); - final boolean countryEquals = streamLocale.getCountry() != null - && streamLocale.getCountry().equals(preferredLocalization.getCountryCode()); - - if (languageEquals) { - if (countryEquals) { - return i; - } - - candidate = i; - } - } - - return candidate; - } - - @NonNull - private String getNameEditText() { - final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString() - .trim(); - - return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); - } - - private void showFailedDialog(@StringRes final int msg) { - new AlertDialog.Builder(context) - .setTitle(R.string.general_error) - .setMessage(msg) - .setNegativeButton(getString(R.string.ok), null) - .show(); - } - - private void launchDirectoryPicker(final ActivityResultLauncher launcher) { - NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG, - context); - } - - private void prepareSelectedDownload() { - final StoredDirectoryHelper mainStorage; - final MediaFormat format; - final String selectedMediaType; - final long size; - - // first, build the filename and get the output folder (if possible) - // later, run a very very very large file checking logic - - filenameTmp = getNameEditText().concat("."); - - final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId(); - if (checkedRadioButtonId == R.id.audio_button) { - selectedMediaType = getString(R.string.last_download_type_audio_key); - mainStorage = mainStorageAudio; - format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); - size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex); - if (format == MediaFormat.WEBMA_OPUS) { - mimeTmp = "audio/ogg"; - filenameTmp += "opus"; - } else if (format != null) { - mimeTmp = format.mimeType; - filenameTmp += format.getSuffix(); - } - } else if (checkedRadioButtonId == R.id.video_button) { - selectedMediaType = getString(R.string.last_download_type_video_key); - mainStorage = mainStorageVideo; - format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex); - if (format != null) { - mimeTmp = format.mimeType; - filenameTmp += format.getSuffix(); - } - } else if (checkedRadioButtonId == R.id.subtitle_button) { - selectedMediaType = getString(R.string.last_download_type_subtitle_key); - mainStorage = mainStorageVideo; // subtitle & video files go together - format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); - size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex); - if (format != null) { - mimeTmp = format.mimeType; - } - - if (format == MediaFormat.TTML) { - filenameTmp += MediaFormat.SRT.getSuffix(); - } else if (format != null) { - filenameTmp += format.getSuffix(); - } - } else { - throw new RuntimeException("No stream selected"); - } - - if (!askForSavePath && (mainStorage == null - || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) - || mainStorage.isInvalidSafStorage())) { - // Pick new download folder if one of: - // - Download folder is not set - // - Download folder uses SAF while SAF is disabled - // - Download folder doesn't use SAF while SAF is enabled - // - Download folder uses SAF but the user manually revoked access to it - Toast.makeText(context, getString(R.string.no_dir_yet), - Toast.LENGTH_LONG).show(); - - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { - launchDirectoryPicker(requestDownloadPickAudioFolderLauncher); - } else { - launchDirectoryPicker(requestDownloadPickVideoFolderLauncher); - } - - return; - } - - if (askForSavePath) { - final Uri initialPath; - if (NewPipeSettings.useStorageAccessFramework(context)) { - initialPath = null; - } else { - final File initialSavePath; - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - } else { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - } - initialPath = Uri.parse(initialSavePath.getAbsolutePath()); - } - - NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher, - StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG, - context); - - return; - } - - // Check for free storage space - final long freeSpace = mainStorage.getFreeStorageSpace(); - if (freeSpace <= size) { - Toast.makeText(context, getString(R. - string.error_insufficient_storage), Toast.LENGTH_LONG).show(); - // move the user to storage setting tab - final Intent storageSettingsIntent = new Intent(Settings. - ACTION_INTERNAL_STORAGE_SETTINGS); - if (storageSettingsIntent.resolveActivity(context.getPackageManager()) - != null) { - startActivity(storageSettingsIntent); - } - return; - } - - // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, - mimeTmp); - - // remember the last media type downloaded by the user - prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) - .apply(); - } - - private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, - final Uri targetFile, - final String filename, - final String mime) { - StoredFileHelper storage; - - try { - if (mainStorage == null) { - // using SAF on older android version - storage = new StoredFileHelper(context, null, targetFile, ""); - } else if (targetFile == null) { - // the file does not exist, but it is probably used in a pending download - storage = new StoredFileHelper(mainStorage.getUri(), filename, mime, - mainStorage.getTag()); - } else { - // the target filename is already use, attempt to use it - storage = new StoredFileHelper(context, mainStorage.getUri(), targetFile, - mainStorage.getTag()); - } - } catch (final Exception e) { - ErrorUtil.createNotification(requireContext(), - new ErrorInfo(e, UserAction.DOWNLOAD_FAILED, "Getting storage")); - return; - } - - // get state of potential mission referring to the same file - final MissionState state = downloadManager.checkForExistingMission(storage); - @StringRes final int msgBtn; - @StringRes final int msgBody; - - // this switch checks if there is already a mission referring to the same file - switch (state) { - case Finished: // there is already a finished mission - msgBtn = R.string.overwrite; - msgBody = R.string.overwrite_finished_warning; - break; - case Pending: - msgBtn = R.string.overwrite; - msgBody = R.string.download_already_pending; - break; - case PendingRunning: - msgBtn = R.string.generate_unique_name; - msgBody = R.string.download_already_running; - break; - case None: // there is no mission referring to the same file - if (mainStorage == null) { - // This part is called if: - // * using SAF on older android version - // * save path not defined - // * if the file exists overwrite it, is not necessary ask - if (!storage.existsAsFile() && !storage.create()) { - showFailedDialog(R.string.error_file_creation); - return; - } - continueSelectedDownload(storage); - return; - } else if (targetFile == null) { - // This part is called if: - // * the filename is not used in a pending/finished download - // * the file does not exists, create - - if (!mainStorage.mkdirs()) { - showFailedDialog(R.string.error_path_creation); - return; - } - - storage = mainStorage.createFile(filename, mime); - if (storage == null || !storage.canWrite()) { - showFailedDialog(R.string.error_file_creation); - return; - } - - continueSelectedDownload(storage); - return; - } - msgBtn = R.string.overwrite; - msgBody = R.string.overwrite_unrelated_warning; - break; - default: - return; // unreachable - } - - final AlertDialog.Builder askDialog = new AlertDialog.Builder(context) - .setTitle(R.string.download_dialog_title) - .setMessage(msgBody) - .setNegativeButton(R.string.cancel, null); - final StoredFileHelper finalStorage = storage; - - - if (mainStorage == null) { - // This part is called if: - // * using SAF on older android version - // * save path not defined - switch (state) { - case Pending: - case Finished: - askDialog.setPositiveButton(msgBtn, (dialog, which) -> { - dialog.dismiss(); - downloadManager.forgetMission(finalStorage); - continueSelectedDownload(finalStorage); - }); - break; - } - - askDialog.show(); - return; - } - - askDialog.setPositiveButton(msgBtn, (dialog, which) -> { - dialog.dismiss(); - - StoredFileHelper storageNew; - switch (state) { - case Finished: - case Pending: - downloadManager.forgetMission(finalStorage); - case None: - if (targetFile == null) { - storageNew = mainStorage.createFile(filename, mime); - } else { - try { - // try take (or steal) the file - storageNew = new StoredFileHelper(context, mainStorage.getUri(), - targetFile, mainStorage.getTag()); - } catch (final IOException e) { - Log.e(TAG, "Failed to take (or steal) the file in " - + targetFile.toString()); - storageNew = null; - } - } - - if (storageNew != null && storageNew.canWrite()) { - continueSelectedDownload(storageNew); - } else { - showFailedDialog(R.string.error_file_creation); - } - break; - case PendingRunning: - storageNew = mainStorage.createUniqueFile(filename, mime); - if (storageNew == null) { - showFailedDialog(R.string.error_file_creation); - } else { - continueSelectedDownload(storageNew); - } - break; - } - }); - - askDialog.show(); - } - - private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { - if (!storage.canWrite()) { - showFailedDialog(R.string.permission_denied); - return; - } - - // check if the selected file has to be overwritten, by simply checking its length - try { - if (storage.length() > 0) { - storage.truncate(); - } - } catch (final IOException e) { - Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e); - showFailedDialog(R.string.overwrite_failed); - return; - } - - final Stream selectedStream; - Stream secondaryStream = null; - final char kind; - int threads = dialogBinding.threads.getProgress() + 1; - final String[] urls; - final List recoveryInfo; - String psName = null; - String[] psArgs = null; - long nearLength = 0; - - // more download logic: select muxer, subtitle converter, etc. - final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId(); - if (checkedRadioButtonId == R.id.audio_button) { - kind = 'a'; - selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); - - if (selectedStream.getFormat() == MediaFormat.M4A) { - psName = Postprocessing.ALGORITHM_M4A_NO_DASH; - } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { - psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; - } - } else if (checkedRadioButtonId == R.id.video_button) { - kind = 'v'; - selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); - - final SecondaryStreamHelper secondary = videoStreamsAdapter - .getAllSecondary() - .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); - - if (secondary != null) { - secondaryStream = secondary.getStream(); - - if (selectedStream.getFormat() == MediaFormat.MPEG_4) { - psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; - } else { - psName = Postprocessing.ALGORITHM_WEBM_MUXER; - } - - final long videoSize = wrappedVideoStreams.getSizeInBytes( - (VideoStream) selectedStream); - - // set nearLength, only, if both sizes are fetched or known. This probably - // does not work on slow networks but is later updated in the downloader - if (secondary.getSizeInBytes() > 0 && videoSize > 0) { - nearLength = secondary.getSizeInBytes() + videoSize; - } - } - } else if (checkedRadioButtonId == R.id.subtitle_button) { - threads = 1; // use unique thread for subtitles due small file size - kind = 's'; - selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); - - if (selectedStream.getFormat() == MediaFormat.TTML) { - psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ - selectedStream.getFormat().getSuffix(), - "false" // ignore empty frames - }; - } - } else { - return; - } - - if (secondaryStream == null) { - urls = new String[] { - selectedStream.getContent() - }; - recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream)); - } else { - if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { - throw new IllegalArgumentException("Unsupported stream delivery format" - + secondaryStream.getDeliveryMethod()); - } - - urls = new String[] { - selectedStream.getContent(), secondaryStream.getContent() - }; - recoveryInfo = List.of( - new MissionRecoveryInfo(selectedStream), - new MissionRecoveryInfo(secondaryStream) - ); - } - - DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); - - Toast.makeText(context, getString(R.string.download_has_started), - Toast.LENGTH_SHORT).show(); - - dismiss(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java deleted file mode 100644 index 9e6861908..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.schabi.newpipe.download; - -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.widget.Toolbar; -import androidx.fragment.app.DialogFragment; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding; - -/** - * This class contains a dialog which shows a loading indicator and has a customizable title. - */ -public class LoadingDialog extends DialogFragment { - private static final String TAG = "LoadingDialog"; - private static final boolean DEBUG = MainActivity.DEBUG; - private DownloadLoadingDialogBinding dialogLoadingBinding; - private final @StringRes int title; - - /** - * Create a new LoadingDialog. - * - *

- * The dialog contains a loading indicator and has a customizable title. - *
- * Use {@code show()} to display the dialog to the user. - *

- * - * @param title an informative title shown in the dialog's toolbar - */ - public LoadingDialog(final @StringRes int title) { - this.title = title; - } - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (DEBUG) { - Log.d(TAG, "onCreate() called with: " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - this.setCancelable(false); - } - - @Override - public View onCreateView( - @NonNull final LayoutInflater inflater, - final ViewGroup container, - final Bundle savedInstanceState) { - if (DEBUG) { - Log.d(TAG, "onCreateView() called with: " - + "inflater = [" + inflater + "], container = [" + container + "], " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - return inflater.inflate(R.layout.download_loading_dialog, container); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view); - initToolbar(dialogLoadingBinding.toolbarLayout.toolbar); - } - - private void initToolbar(final Toolbar toolbar) { - if (DEBUG) { - Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); - } - toolbar.setTitle(requireContext().getString(title)); - toolbar.setNavigationOnClickListener(v -> dismiss()); - - } - - @Override - public void onDestroyView() { - dialogLoadingBinding = null; - super.onDestroyView(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java deleted file mode 100644 index 90d8f4797..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.schabi.newpipe.error; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import org.acra.ReportField; -import org.acra.data.CrashReportData; -import org.acra.sender.ReportSender; -import org.schabi.newpipe.R; - -/* - * Created by Christian Schabesberger on 13.09.16. - * - * Copyright (C) Christian Schabesberger 2015 - * AcraReportSender.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class AcraReportSender implements ReportSender { - - @Override - public void send(@NonNull final Context context, @NonNull final CrashReportData report) { - ErrorUtil.openActivity(context, new ErrorInfo( - new String[]{report.getString(ReportField.STACK_TRACE)}, - UserAction.UI_ERROR, - "ACRA report", - null, - R.string.app_ui_crash)); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java deleted file mode 100644 index e63d55063..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.schabi.newpipe.error; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.google.auto.service.AutoService; - -import org.acra.config.CoreConfiguration; -import org.acra.sender.ReportSender; -import org.acra.sender.ReportSenderFactory; -import org.schabi.newpipe.App; - -/* - * Created by Christian Schabesberger on 13.09.16. - * - * Copyright (C) Christian Schabesberger 2015 - * AcraReportSenderFactory.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -/** - * Used by ACRA in {@link App}.initAcra() as the factory for report senders. - */ -@AutoService(ReportSenderFactory.class) -public class AcraReportSenderFactory implements ReportSenderFactory { - @NonNull - public ReportSender create(@NonNull final Context context, - @NonNull final CoreConfiguration config) { - return new AcraReportSender(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt deleted file mode 100644 index c68a2cfd1..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt +++ /dev/null @@ -1,282 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2015-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.error - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.IntentCompat -import androidx.core.net.toUri -import com.grack.nanojson.JsonWriter -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ActivityErrorBinding -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.text.setTextWithLinks - -/** - * This activity is used to show error details and allow reporting them in various ways. - * Use [ErrorUtil.openActivity] to correctly open this activity. - */ -class ErrorActivity : AppCompatActivity() { - private lateinit var errorInfo: ErrorInfo - private lateinit var currentTimeStamp: String - - private lateinit var binding: ActivityErrorBinding - - private val contentCountryString: String - get() = Localization.getPreferredContentCountry(this).countryCode - - private val contentLanguageString: String - get() = Localization.getPreferredLocalization(this).localizationCode - - private val appLanguage: String - get() = Localization.getAppLocale().toString() - - private val osString: String - get() { - val name = System.getProperty("os.name")!! - val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Build.VERSION.BASE_OS.ifEmpty { "Android" } - } else { - "Android" - } - return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}" - } - - private val errorEmailSubject: String - get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}" - - // ///////////////////////////////////////////////////////////////////// - // Activity lifecycle - // ///////////////////////////////////////////////////////////////////// - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - ThemeHelper.setDayNightMode(this) - ThemeHelper.setTheme(this) - - binding = ActivityErrorBinding.inflate(layoutInflater) - setContentView(binding.getRoot()) - - setSupportActionBar(binding.toolbarLayout.toolbar) - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(true) - setTitle(R.string.error_report_title) - setDisplayShowTitleEnabled(true) - } - - errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!! - - // important add guru meditation - addGuruMeditation() - // print current time, as zoned ISO8601 timestamp - currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) - - binding.errorReportEmailButton.setOnClickListener { _ -> - openPrivacyPolicyDialog(this, "EMAIL") - } - - binding.errorReportCopyButton.setOnClickListener { _ -> - ShareUtils.copyToClipboard(this, buildMarkdown()) - } - - binding.errorReportGitHubButton.setOnClickListener { _ -> - openPrivacyPolicyDialog(this, "GITHUB") - } - - // normal bugreport - buildInfo(errorInfo) - binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this)) - binding.errorView.text = formErrorText(errorInfo.stackTraces) - - // print stack trace once again for debugging: - errorInfo.stackTraces.forEach { Log.e(TAG, it) } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.error_menu, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - - R.id.menu_item_share_error -> { - ShareUtils.shareText( - applicationContext, - getString(R.string.error_report_title), - buildJson() - ) - true - } - - else -> false - } - } - - private fun openPrivacyPolicyDialog(context: Context, action: String) { - AlertDialog.Builder(context) - .setIcon(android.R.drawable.ic_dialog_alert) - .setTitle(R.string.privacy_policy_title) - .setMessage(R.string.start_accept_privacy_policy) - .setCancelable(false) - .setNeutralButton(R.string.read_privacy_policy) { _, _ -> - ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url)) - } - .setPositiveButton(R.string.accept) { _, _ -> - if (action == "EMAIL") { // send on email - val intent = Intent(Intent.ACTION_SENDTO) - .setData("mailto:".toUri()) // only email apps should handle this - .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS)) - .putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject) - .putExtra(Intent.EXTRA_TEXT, buildJson()) - ShareUtils.openIntentInApp(context, intent) - } else if (action == "GITHUB") { // open the NewPipe issue page on GitHub - ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL) - } - } - .setNegativeButton(R.string.decline, null) - .show() - } - - private fun formErrorText(stacktrace: Array): String { - val separator = "-------------------------------------" - return stacktrace.joinToString(separator + "\n", separator + "\n", separator) - } - - private fun buildInfo(info: ErrorInfo) { - binding.errorInfoLabelsView.text = getString(R.string.info_labels) - - val text = info.userAction.message + "\n" + - info.request + "\n" + - contentLanguageString + "\n" + - contentCountryString + "\n" + - appLanguage + "\n" + - info.getServiceName() + "\n" + - currentTimeStamp + "\n" + - packageName + "\n" + - BuildConfig.VERSION_NAME + "\n" + - osString - - binding.errorInfosView.text = text - } - - private fun buildJson(): String { - try { - return JsonWriter.string() - .`object`() - .value("user_action", errorInfo.userAction.message) - .value("request", errorInfo.request) - .value("content_language", contentLanguageString) - .value("content_country", contentCountryString) - .value("app_language", appLanguage) - .value("service", errorInfo.getServiceName()) - .value("package", packageName) - .value("version", BuildConfig.VERSION_NAME) - .value("os", osString) - .value("time", currentTimeStamp) - .array("exceptions", errorInfo.stackTraces.toList()) - .value("user_comment", binding.errorCommentBox.getText().toString()) - .end() - .done() - } catch (exception: Exception) { - Log.e(TAG, "Error while erroring: Could not build json", exception) - } - - return "" - } - - private fun buildMarkdown(): String { - try { - return buildString(1024) { - val userComment = binding.errorCommentBox.text.toString() - if (userComment.isNotEmpty()) { - appendLine(userComment) - } - - // basic error info - appendLine("## Exception") - appendLine("* __User Action:__ ${errorInfo.userAction.message}") - appendLine("* __Request:__ ${errorInfo.request}") - appendLine("* __Content Country:__ $contentCountryString") - appendLine("* __Content Language:__ $contentLanguageString") - appendLine("* __App Language:__ $appLanguage") - appendLine("* __Service:__ ${errorInfo.getServiceName()}") - appendLine("* __Timestamp:__ $currentTimeStamp") - appendLine("* __Package:__ $packageName") - appendLine("* __Service:__ ${errorInfo.getServiceName()}") - appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}") - appendLine("* __OS:__ $osString") - - // Collapse all logs to a single paragraph when there are more than one - // to keep the GitHub issue clean. - if (errorInfo.stackTraces.size > 1) { - append("
Exceptions (") - append(errorInfo.stackTraces.size) - append(")

\n") - } - - // add the logs - errorInfo.stackTraces.forEachIndexed { index, stacktrace -> - append("

Crash log ") - if (errorInfo.stackTraces.size > 1) { - append(index + 1) - } - append("") - append("

\n") - append("\n```\n${stacktrace}\n```\n") - append("

\n") - } - - // make sure to close everything - if (errorInfo.stackTraces.size > 1) { - append("

\n") - } - - append("
\n") - } - } catch (exception: Exception) { - Log.e(TAG, "Error while erroring: Could not build markdown", exception) - return "" - } - } - - private fun addGuruMeditation() { - // just an easter egg - var text = binding.errorSorryView.text.toString() - text += "\n" + getString(R.string.guru_meditation) - binding.errorSorryView.text = text - } - - companion object { - // LOG TAGS - private val TAG = ErrorActivity::class.java.toString() - - // BUNDLE TAGS - const val ERROR_INFO = "error_info" - - private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org" - private const val ERROR_EMAIL_SUBJECT = "Exception in " - - private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt deleted file mode 100644 index 82f7d84bf..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ /dev/null @@ -1,364 +0,0 @@ -package org.schabi.newpipe.error - -import android.content.Context -import android.os.Parcelable -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import com.google.android.exoplayer2.ExoPlaybackException -import com.google.android.exoplayer2.upstream.HttpDataSource -import com.google.android.exoplayer2.upstream.Loader -import java.net.UnknownHostException -import kotlinx.parcelize.Parcelize -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.Info -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.ServiceList.YouTube -import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException -import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException -import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException -import org.schabi.newpipe.extractor.exceptions.PaidContentException -import org.schabi.newpipe.extractor.exceptions.PrivateContentException -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException -import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException -import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException -import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException -import org.schabi.newpipe.ktx.isNetworkRelated -import org.schabi.newpipe.player.mediasource.FailedMediaSource -import org.schabi.newpipe.player.resolver.PlaybackResolver -import org.schabi.newpipe.util.text.getText - -/** - * An error has occurred in the app. This class contains plain old parcelable data that can be used - * to report the error and to show it to the user along with correct action buttons. - */ -@Parcelize -class ErrorInfo private constructor( - val stackTraces: Array, - val userAction: UserAction, - val request: String, - val serviceId: Int?, - private val message: ErrorMessage, - /** - * If `true`, a report button will be shown for this error. Otherwise the error is not something - * that can really be reported (e.g. a network issue, or content not being available at all). - */ - val isReportable: Boolean, - /** - * If `true`, the process causing this error can be retried, otherwise not. - */ - val isRetryable: Boolean, - /** - * If present, indicates that the exception was a ReCaptchaException, and this is the URL - * provided by the service that can be used to solve the ReCaptcha challenge. - */ - val recaptchaUrl: String?, - /** - * If present, this resource can alternatively be opened in browser (useful if NewPipe is - * badly broken). - */ - val openInBrowserUrl: String? -) : Parcelable { - - @JvmOverloads - constructor( - throwable: Throwable, - userAction: UserAction, - request: String, - serviceId: Int? = null, - openInBrowserUrl: String? = null - ) : this( - throwableToStringList(throwable), - userAction, - request, - serviceId, - getMessage(throwable, userAction, serviceId), - isReportable(throwable), - isRetryable(throwable), - (throwable as? ReCaptchaException)?.url, - openInBrowserUrl - ) - - @JvmOverloads - constructor( - throwables: List, - userAction: UserAction, - request: String, - serviceId: Int? = null, - openInBrowserUrl: String? = null - ) : this( - throwableListToStringList(throwables), - userAction, - request, - serviceId, - getMessage(throwables.firstOrNull(), userAction, serviceId), - throwables.any(::isReportable), - throwables.isEmpty() || throwables.any(::isRetryable), - throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url, - openInBrowserUrl - ) - - // constructor to manually build ErrorInfo when no throwable is available - constructor( - stackTraces: Array, - userAction: UserAction, - request: String, - serviceId: Int?, - @StringRes message: Int - ) : - this( - stackTraces, userAction, request, serviceId, ErrorMessage(message), - true, false, null, null - ) - - // constructor with only one throwable to extract service id and openInBrowserUrl from an Info - constructor( - throwable: Throwable, - userAction: UserAction, - request: String, - info: Info? - ) : - this(throwable, userAction, request, info?.serviceId, info?.url) - - // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info - constructor( - throwables: List, - userAction: UserAction, - request: String, - info: Info? - ) : - this(throwables, userAction, request, info?.serviceId, info?.url) - - fun getServiceName(): String { - return getServiceName(serviceId) - } - - fun getMessage(context: Context): CharSequence { - return message.getText(context) - } - - companion object { - @Parcelize - class ErrorMessage( - @StringRes - private val stringRes: Int, - private vararg val formatArgs: String - ) : Parcelable { - fun getText(context: Context): CharSequence { - // Ensure locale aware context via ContextCompat.getContextForLanguage() (just in case context is not AppCompatActivity) - val ctx = ContextCompat.getContextForLanguage(context) - return if (formatArgs.isEmpty()) { - ctx.getText(stringRes) - } else { - // ContextCompat.getString() with formatArgs does not exist, so we just - // replicate its source code but with formatArgs - ctx.resources.getText(stringRes, *formatArgs) - } - } - } - - const val SERVICE_NONE = "" - - const val YOUTUBE_IP_BAN_FAQ_URL = "https://newpipe.net/FAQ/#ip-banned-youtube" - - private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we - // want to default to SERVICE_NONE - ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name - ?: SERVICE_NONE - - fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString()) - - fun throwableListToStringList(throwableList: List) = throwableList.map { it.stackTraceToString() }.toTypedArray() - - fun getMessage( - throwable: Throwable?, - action: UserAction?, - serviceId: Int? - ): ErrorMessage { - return when { - // player exceptions - // some may be IOException, so do these checks before isNetworkRelated! - throwable is ExoPlaybackException -> { - val cause = throwable.cause - when { - cause is HttpDataSource.InvalidResponseCodeException -> { - if (cause.responseCode == 403) { - if (serviceId == YouTube.serviceId) { - ErrorMessage(R.string.youtube_player_http_403) - } else { - ErrorMessage(R.string.player_http_403) - } - } else { - ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString()) - } - } - - cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException -> - getMessage(throwable, action, serviceId) - - throwable.type == ExoPlaybackException.TYPE_SOURCE -> - ErrorMessage(R.string.player_stream_failure) - - throwable.type == ExoPlaybackException.TYPE_UNEXPECTED -> - ErrorMessage(R.string.player_recoverable_failure) - - else -> - ErrorMessage(R.string.player_unrecoverable_failure) - } - } - - throwable is FailedMediaSource.FailedMediaSourceException -> - getMessage(throwable.cause, action, serviceId) - - throwable is PlaybackResolver.ResolverException -> - ErrorMessage(R.string.player_stream_failure) - - // content not available exceptions - throwable is AccountTerminatedException -> - throwable.message - ?.takeIf { reason -> !reason.isEmpty() } - ?.let { reason -> - ErrorMessage( - R.string.account_terminated_service_provides_reason, - getServiceName(serviceId), - reason - ) - } - ?: ErrorMessage(R.string.account_terminated) - - throwable is AgeRestrictedContentException -> - ErrorMessage(R.string.restricted_video_no_stream) - - throwable is GeographicRestrictionException -> - ErrorMessage(R.string.georestricted_content) - - throwable is PaidContentException -> - ErrorMessage(R.string.paid_content) - - throwable is PrivateContentException -> - ErrorMessage(R.string.private_content) - - throwable is SoundCloudGoPlusContentException -> - ErrorMessage(R.string.soundcloud_go_plus_content) - - throwable is UnsupportedContentInCountryException -> - ErrorMessage(R.string.unsupported_content_in_country) - - throwable is YoutubeMusicPremiumContentException -> - ErrorMessage(R.string.youtube_music_premium_content) - - throwable is SignInConfirmNotBotException -> - ErrorMessage( - R.string.sign_in_confirm_not_bot_error, - getServiceName(serviceId), - YOUTUBE_IP_BAN_FAQ_URL - ) - - throwable is ContentNotAvailableException -> - ErrorMessage(R.string.content_not_available) - - // other extractor exceptions - throwable is ContentNotSupportedException -> - ErrorMessage(R.string.content_not_supported) - - // ReCaptchas will be handled in a special way anyway - throwable is ReCaptchaException -> - ErrorMessage(R.string.recaptcha_request_toast) - - // test this at the end as many exceptions could be a subclass of IOException - throwable != null && throwable.isNetworkRelated -> - ErrorMessage(R.string.network_error) - - // an extraction exception unrelated to the network - // is likely an issue with parsing the website - throwable is ExtractionException -> - ErrorMessage(R.string.parsing_error) - - // user actions (in case the exception is null or unrecognizable) - action == UserAction.UI_ERROR -> - ErrorMessage(R.string.app_ui_crash) - - action == UserAction.REQUESTED_COMMENTS -> - ErrorMessage(R.string.error_unable_to_load_comments) - - action == UserAction.SUBSCRIPTION_CHANGE -> - ErrorMessage(R.string.subscription_change_failed) - - action == UserAction.SUBSCRIPTION_UPDATE -> - ErrorMessage(R.string.subscription_update_failed) - - action == UserAction.LOAD_IMAGE -> - ErrorMessage(R.string.could_not_load_thumbnails) - - action == UserAction.DOWNLOAD_OPEN_DIALOG -> - ErrorMessage(R.string.could_not_setup_download_menu) - - else -> - ErrorMessage(R.string.error_snackbar_message) - } - } - - fun isReportable(throwable: Throwable?): Boolean { - return when (throwable) { - // we don't have an exception, so this is a manually built error, which likely - // indicates that it's important and is thus reportable - null -> true - - // if the service explicitly said that content is not available (e.g. age - // restrictions, video deleted, etc.), there is no use in letting users report it - is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable) - - // we know the content is not supported, no need to let the user report it - is ContentNotSupportedException -> false - - // happens often when there is no internet connection; we don't use - // `throwable.isNetworkRelated` since any `IOException` would make that function - // return true, but not all `IOException`s are network related - is UnknownHostException -> false - - // by default, this is an unexpected exception, which the user could report - else -> true - } - } - - fun isRetryable(throwable: Throwable?): Boolean { - return when (throwable) { - // if we know the content is surely not available, retrying won't help - is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable) - - // we know the content is not supported, retrying won't help - is ContentNotSupportedException -> false - - // by default (including if throwable is null), enable retrying (though the retry - // button will be shown only if a way to perform the retry is implemented) - else -> true - } - } - - /** - * Unfortunately sometimes [ContentNotAvailableException] may not indicate that the content - * is blocked/deleted/paid, but may just indicate that we could not extract it. This is an - * inconsistency in the exceptions thrown by the extractor, but until it is fixed, this - * function will distinguish between the two types. - * @return `true` if the content is not available because of a limitation imposed by the - * service or the owner, `false` if the extractor could not extract info about it - */ - fun isContentSurelyNotAvailable(e: ContentNotAvailableException): Boolean { - return when (e) { - is AccountTerminatedException, - is AgeRestrictedContentException, - is GeographicRestrictionException, - is PaidContentException, - is PrivateContentException, - is SoundCloudGoPlusContentException, - is UnsupportedContentInCountryException, - is YoutubeMusicPremiumContentException -> true - - else -> false - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt deleted file mode 100644 index 8136c78d8..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.schabi.newpipe.error - -import android.content.Context -import android.content.Intent -import android.view.View -import android.widget.Button -import android.widget.TextView -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import com.jakewharton.rxbinding4.view.clicks -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.Disposable -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.text.setTextWithLinks - -class ErrorPanelHelper( - private val fragment: Fragment, - rootView: View, - onRetry: Runnable? -) { - private val context: Context = rootView.context!! - - private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel) - - // the only element that is visible by default - private val errorTextView: TextView = - errorPanelRoot.findViewById(R.id.error_message_view) - private val errorServiceInfoTextView: TextView = - errorPanelRoot.findViewById(R.id.error_message_service_info_view) - private val errorServiceExplanationTextView: TextView = - errorPanelRoot.findViewById(R.id.error_message_service_explanation_view) - private val errorActionButton: Button = - errorPanelRoot.findViewById(R.id.error_action_button) - private val errorRetryButton: Button = - errorPanelRoot.findViewById(R.id.error_retry_button) - private val errorOpenInBrowserButton: Button = - errorPanelRoot.findViewById(R.id.error_open_in_browser) - - private var errorDisposable: Disposable? = null - private var retryShouldBeShown: Boolean = (onRetry != null) - - init { - if (onRetry != null) { - errorDisposable = errorRetryButton.clicks() - .debounce(300, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { onRetry.run() } - } - } - - private fun ensureDefaultVisibility() { - errorTextView.isVisible = true - - errorServiceInfoTextView.isVisible = false - errorServiceExplanationTextView.isVisible = false - errorActionButton.isVisible = false - errorRetryButton.isVisible = false - errorOpenInBrowserButton.isVisible = false - } - - fun showError(errorInfo: ErrorInfo) { - ensureDefaultVisibility() - errorTextView.setTextWithLinks(errorInfo.getMessage(context)) - - if (errorInfo.recaptchaUrl != null) { - showAndSetErrorButtonAction(R.string.recaptcha_solve) { - // Starting ReCaptcha Challenge Activity - val intent = Intent(context, ReCaptchaActivity::class.java) - intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl) - fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST) - errorActionButton.setOnClickListener(null) - } - } else if (errorInfo.isReportable) { - showAndSetErrorButtonAction(R.string.error_snackbar_action) { - ErrorUtil.openActivity(context, errorInfo) - } - } - - if (errorInfo.isRetryable) { - errorRetryButton.isVisible = retryShouldBeShown - } - - if (errorInfo.openInBrowserUrl != null) { - errorOpenInBrowserButton.isVisible = true - errorOpenInBrowserButton.setOnClickListener { - ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl) - } - } - - setRootVisible() - } - - /** - * Shows the errorButtonAction, sets a text into it and sets the click listener. - */ - private fun showAndSetErrorButtonAction( - @StringRes resid: Int, - listener: View.OnClickListener - ) { - errorActionButton.isVisible = true - errorActionButton.setText(resid) - errorActionButton.setOnClickListener(listener) - } - - fun showTextError(errorString: String) { - ensureDefaultVisibility() - - errorTextView.setTextWithLinks(errorString) - - setRootVisible() - } - - private fun setRootVisible() { - errorPanelRoot.animate(true, 300) - } - - fun hide() { - errorActionButton.setOnClickListener(null) - errorPanelRoot.animate(false, 150) - } - - fun isVisible(): Boolean { - return errorPanelRoot.isVisible - } - - fun dispose() { - errorActionButton.setOnClickListener(null) - errorRetryButton.setOnClickListener(null) - errorDisposable?.dispose() - } - - companion object { - val TAG: String = ErrorPanelHelper::class.simpleName!! - val DEBUG: Boolean = MainActivity.DEBUG - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt deleted file mode 100644 index 0fa302623..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt +++ /dev/null @@ -1,170 +0,0 @@ -package org.schabi.newpipe.error - -import android.app.Activity -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.view.View -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceManager -import com.google.android.material.snackbar.Snackbar -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R - -/** - * This class contains all of the methods that should be used to let the user know that an error has - * occurred in the least intrusive way possible for each case. This class is for unexpected errors, - * for handled errors (e.g. network errors) use e.g. [ErrorPanelHelper] instead. - * - Use a snackbar if the exception is not critical and it happens in a place where a root view - * is available. - * - Use a notification if the exception happens inside a background service (player, subscription - * import, ...) or there is no activity/fragment from which to extract a root view. - * - Finally use the error activity only as a last resort in case the exception is critical and - * happens in an open activity (since the workflow would be interrupted anyway in that case). - */ -class ErrorUtil { - companion object { - private const val ERROR_REPORT_NOTIFICATION_ID = 5340681 - - /** - * Starts a new error activity allowing the user to report the provided error. Only use this - * method directly as a last resort in case the exception is critical and happens in an open - * activity (since the workflow would be interrupted anyway in that case). So never use this - * for background services. - * - * If the crashed occurred while the app was in the background open a notification instead - * - * @param context the context to use to start the new activity - * @param errorInfo the error info to be reported - */ - @JvmStatic - fun openActivity(context: Context, errorInfo: ErrorInfo) { - if (PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true) - ) { - createNotification(context, errorInfo) - } else { - context.startActivity(getErrorActivityIntent(context, errorInfo)) - } - } - - /** - * Show a bottom snackbar to the user, with a report button that opens the error activity. - * Use this method if the exception is not critical and it happens in a place where a root - * view is available. - * - * @param context will be used to obtain the root view if it is an [Activity]; if no root - * view can be found an error notification is shown instead - * @param errorInfo the error info to be reported - */ - @JvmStatic - fun showSnackbar(context: Context, errorInfo: ErrorInfo) { - val rootView = (context as? Activity)?.findViewById(android.R.id.content) - showSnackbar(context, rootView, errorInfo) - } - - /** - * Show a bottom snackbar to the user, with a report button that opens the error activity. - * Use this method if the exception is not critical and it happens in a place where a root - * view is available. - * - * @param fragment will be used to obtain the root view if it has a connected [Activity]; if - * no root view can be found an error notification is shown instead - * @param errorInfo the error info to be reported - */ - @JvmStatic - fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) { - var rootView = fragment.view - if (rootView == null && fragment.activity != null) { - rootView = fragment.requireActivity().findViewById(android.R.id.content) - } - showSnackbar(fragment.requireContext(), rootView, errorInfo) - } - - /** - * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] - */ - @JvmStatic - fun showUiErrorSnackbar(context: Context, request: String, throwable: Throwable) { - showSnackbar(context, ErrorInfo(throwable, UserAction.UI_ERROR, request)) - } - - /** - * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] - */ - @JvmStatic - fun showUiErrorSnackbar(fragment: Fragment, request: String, throwable: Throwable) { - showSnackbar(fragment, ErrorInfo(throwable, UserAction.UI_ERROR, request)) - } - - /** - * Create an error notification. Tapping on the notification opens the error activity. Use - * this method if the exception happens inside a background service (player, subscription - * import, ...) or there is no activity/fragment from which to extract a root view. - * - * @param context the context to use to show the notification - * @param errorInfo the error info to be reported; the error message - * [ErrorInfo.messageStringId] will be shown in the notification - * description - */ - @JvmStatic - fun createNotification(context: Context, errorInfo: ErrorInfo) { - val notificationBuilder: NotificationCompat.Builder = - NotificationCompat.Builder( - context, - context.getString(R.string.error_report_channel_id) - ) - .setSmallIcon(R.drawable.ic_bug_report) - .setContentTitle(context.getString(R.string.error_report_notification_title)) - .setContentText(errorInfo.getMessage(context)) - .setAutoCancel(true) - .setContentIntent( - PendingIntentCompat.getActivity( - context, - 0, - getErrorActivityIntent(context, errorInfo), - PendingIntent.FLAG_UPDATE_CURRENT, - false - ) - ) - - val notificationManager = NotificationManagerCompat.from(context) - if (notificationManager.areNotificationsEnabled()) { - notificationManager - .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) - } - - ContextCompat.getMainExecutor(context).execute { - // since the notification is silent, also show a toast, otherwise the user is confused - Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) - .show() - } - } - - private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent { - val intent = Intent(context, ErrorActivity::class.java) - intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return intent - } - - private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) { - if (rootView == null) { - // fallback to showing a notification if no root view is available - createNotification(context, errorInfo) - } else { - Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG) - .setActionTextColor(Color.YELLOW) - .setAction(context.getString(R.string.error_snackbar_action).uppercase()) { - context.startActivity(getErrorActivityIntent(context, errorInfo)) - }.show() - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java deleted file mode 100644 index 811671039..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ /dev/null @@ -1,234 +0,0 @@ -package org.schabi.newpipe.error; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.webkit.CookieManager; -import android.webkit.WebResourceRequest; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.NavUtils; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; -import org.schabi.newpipe.extractor.utils.Utils; -import org.schabi.newpipe.util.ThemeHelper; - -/* - * Created by beneth on 06.12.16. - * - * Copyright (C) Christian Schabesberger 2015 - * ReCaptchaActivity.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -public class ReCaptchaActivity extends AppCompatActivity { - public static final int RECAPTCHA_REQUEST = 10; - public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra"; - public static final String TAG = ReCaptchaActivity.class.toString(); - public static final String YT_URL = "https://www.youtube.com"; - public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies"; - - public static String sanitizeRecaptchaUrl(@Nullable final String url) { - if (url == null || url.trim().isEmpty()) { - return YT_URL; // YouTube is the most likely service to have thrown a recaptcha - } else { - // remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML - return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", ""); - } - } - - private ActivityRecaptchaBinding recaptchaBinding; - private String foundCookies = ""; - - @SuppressLint("SetJavaScriptEnabled") - @Override - protected void onCreate(final Bundle savedInstanceState) { - ThemeHelper.setTheme(this); - super.onCreate(savedInstanceState); - - recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater()); - setContentView(recaptchaBinding.getRoot()); - setSupportActionBar(recaptchaBinding.toolbar); - - final String url = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA)); - // set return to Cancel by default - setResult(RESULT_CANCELED); - - // enable Javascript - final WebSettings webSettings = recaptchaBinding.reCaptchaWebView.getSettings(); - webSettings.setJavaScriptEnabled(true); - webSettings.setUserAgentString(DownloaderImpl.USER_AGENT); - - recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() { - @Override - public boolean shouldOverrideUrlLoading(final WebView view, - final WebResourceRequest request) { - if (MainActivity.DEBUG) { - Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString()); - } - - handleCookiesFromUrl(request.getUrl().toString()); - return false; - } - - @Override - public void onPageFinished(final WebView view, final String url) { - super.onPageFinished(view, url); - handleCookiesFromUrl(url); - } - }); - - // cleaning cache, history and cookies from webView - recaptchaBinding.reCaptchaWebView.clearCache(true); - recaptchaBinding.reCaptchaWebView.clearHistory(); - CookieManager.getInstance().removeAllCookies(null); - - recaptchaBinding.reCaptchaWebView.loadUrl(url); - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - getMenuInflater().inflate(R.menu.menu_recaptcha, menu); - - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(false); - actionBar.setTitle(R.string.title_activity_recaptcha); - actionBar.setSubtitle(R.string.subtitle_activity_recaptcha); - } - - return true; - } - - @Override - @SuppressLint("MissingSuperCall") // saveCookiesAndFinish method handles back navigation - public void onBackPressed() { - saveCookiesAndFinish(); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == R.id.menu_item_done) { - saveCookiesAndFinish(); - return true; - } - return false; - } - - private void saveCookiesAndFinish() { - // try to get cookies of unclosed page - handleCookiesFromUrl(recaptchaBinding.reCaptchaWebView.getUrl()); - if (MainActivity.DEBUG) { - Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies); - } - - if (!foundCookies.isEmpty()) { - // save cookies to preferences - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( - getApplicationContext()); - final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); - prefs.edit().putString(key, foundCookies).apply(); - - // give cookies to Downloader class - DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies); - setResult(RESULT_OK); - } - - // Navigate to blank page (unloads youtube to prevent background playback) - recaptchaBinding.reCaptchaWebView.loadUrl("about:blank"); - - final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - NavUtils.navigateUpTo(this, intent); - } - - - private void handleCookiesFromUrl(@Nullable final String url) { - if (MainActivity.DEBUG) { - Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url)); - } - - if (url == null) { - return; - } - - final String cookies = CookieManager.getInstance().getCookie(url); - handleCookies(cookies); - - // sometimes cookies are inside the url - final int abuseStart = url.indexOf("google_abuse="); - if (abuseStart != -1) { - final int abuseEnd = url.indexOf("+path"); - - try { - handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd))); - } catch (final StringIndexOutOfBoundsException e) { - if (MainActivity.DEBUG) { - Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at " - + abuseStart + " and ending at " + abuseEnd + " for url " + url, e); - } - } - } - } - - private void handleCookies(@Nullable final String cookies) { - if (MainActivity.DEBUG) { - Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies)); - } - - if (cookies == null) { - return; - } - - addYoutubeCookies(cookies); - // add here methods to extract cookies for other services - } - - private void addYoutubeCookies(@NonNull final String cookies) { - if (cookies.contains("s_gl=") || cookies.contains("goojf=") - || cookies.contains("VISITOR_INFO1_LIVE=") - || cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) { - // youtube seems to also need the other cookies: - addCookie(cookies); - } - } - - private void addCookie(final String cookie) { - if (foundCookies.contains(cookie)) { - return; - } - - if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) { - foundCookies += cookie; - } else if (foundCookies.endsWith(";")) { - foundCookies += " " + cookie; - } else { - foundCookies += "; " + cookie; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt deleted file mode 100644 index b3f14e2da..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.error - -/** - * The user actions that can cause an error. - */ -enum class UserAction(val message: String) { - USER_REPORT("user report"), - UI_ERROR("ui error"), - DATABASE_IMPORT_EXPORT("database import or export"), - SUBSCRIPTION_CHANGE("subscription change"), - SUBSCRIPTION_UPDATE("subscription update"), - SUBSCRIPTION_GET("get subscription"), - SUBSCRIPTION_IMPORT_EXPORT("subscription import or export"), - LOAD_IMAGE("load image"), - SOMETHING_ELSE("something else"), - SEARCHED("searched"), - GET_SUGGESTIONS("get suggestions"), - REQUESTED_STREAM("requested stream"), - REQUESTED_CHANNEL("requested channel"), - REQUESTED_PLAYLIST("requested playlist"), - REQUESTED_KIOSK("requested kiosk"), - REQUESTED_COMMENTS("requested comments"), - REQUESTED_COMMENT_REPLIES("requested comment replies"), - REQUESTED_FEED("requested feed"), - REQUESTED_BOOKMARK("bookmark"), - DELETE_FROM_HISTORY("delete from history"), - PLAY_STREAM("play stream"), - DOWNLOAD_OPEN_DIALOG("download open dialog"), - DOWNLOAD_POSTPROCESSING("download post-processing"), - DOWNLOAD_FAILED("download failed"), - NEW_STREAMS_NOTIFICATIONS("new streams notifications"), - PREFERENCES_MIGRATION("migration of preferences"), - SHARE_TO_NEWPIPE("share to newpipe"), - CHECK_FOR_NEW_APP_VERSION("check for new app version"), - OPEN_INFO_ITEM_DIALOG("open info item dialog"), - GETTING_MAIN_SCREEN_TAB("getting main screen tab"), - PLAY_ON_POPUP("play on popup"), - SUBSCRIPTIONS("loading subscriptions") -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java b/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java deleted file mode 100644 index 6add5eb09..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.fragments; - -/** - * Indicates that the current fragment can handle back presses. - */ -public interface BackPressable { - /** - * A back press was delegated to this fragment. - * - * @return if the back press was handled - */ - boolean onBackPressed(); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java deleted file mode 100644 index 8361953b9..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ /dev/null @@ -1,227 +0,0 @@ -package org.schabi.newpipe.fragments; - -import static org.schabi.newpipe.ktx.ViewUtils.animate; - -import android.os.Bundle; -import android.util.Log; -import android.view.View; -import android.widget.ProgressBar; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorPanelHelper; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.util.InfoCache; - -import java.util.concurrent.atomic.AtomicBoolean; - -public abstract class BaseStateFragment extends BaseFragment implements ViewContract { - @State - protected AtomicBoolean wasLoading = new AtomicBoolean(); - protected AtomicBoolean isLoading = new AtomicBoolean(); - - @Nullable - protected View emptyStateView; - @Nullable - protected TextView emptyStateMessageView; - @Nullable - private ProgressBar loadingProgressBar; - - private ErrorPanelHelper errorPanelHelper; - @Nullable - @State - protected ErrorInfo lastPanelError = null; - - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - doInitialLoadLogic(); - } - - @Override - public void onPause() { - super.onPause(); - wasLoading.set(isLoading.get()); - } - - @Override - public void onResume() { - super.onResume(); - if (lastPanelError != null) { - showError(lastPanelError); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - emptyStateView = rootView.findViewById(R.id.empty_state_view); - emptyStateMessageView = rootView.findViewById(R.id.empty_state_message); - loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); - errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (errorPanelHelper != null) { - errorPanelHelper.dispose(); - } - emptyStateView = null; - emptyStateMessageView = null; - } - - protected void onRetryButtonClicked() { - reloadContent(); - } - - public void reloadContent() { - startLoading(true); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load - //////////////////////////////////////////////////////////////////////////*/ - - protected void doInitialLoadLogic() { - startLoading(true); - } - - protected void startLoading(final boolean forceLoad) { - if (DEBUG) { - Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]"); - } - showLoading(); - isLoading.set(true); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - if (emptyStateView != null) { - animate(emptyStateView, false, 150); - } - if (loadingProgressBar != null) { - animate(loadingProgressBar, true, 400); - } - hideErrorPanel(); - } - - @Override - public void hideLoading() { - if (emptyStateView != null) { - animate(emptyStateView, false, 150); - } - if (loadingProgressBar != null) { - animate(loadingProgressBar, false, 0); - } - hideErrorPanel(); - } - - @Override - public void showEmptyState() { - isLoading.set(false); - if (emptyStateView != null) { - animate(emptyStateView, true, 200); - } - if (loadingProgressBar != null) { - animate(loadingProgressBar, false, 0); - } - hideErrorPanel(); - } - - @Override - public void handleResult(final I result) { - if (DEBUG) { - Log.d(TAG, "handleResult() called with: result = [" + result + "]"); - } - hideLoading(); - } - - @Override - public void handleError() { - isLoading.set(false); - InfoCache.getInstance().clearCache(); - if (emptyStateView != null) { - animate(emptyStateView, false, 150); - } - if (loadingProgressBar != null) { - animate(loadingProgressBar, false, 0); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - public final void showError(final ErrorInfo errorInfo) { - handleError(); - - if (isDetached() || isRemoving()) { - if (DEBUG) { - Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]"); - } - return; - } - - errorPanelHelper.showError(errorInfo); - lastPanelError = errorInfo; - } - - public final void showTextError(@NonNull final String errorString) { - handleError(); - - if (isDetached() || isRemoving()) { - if (DEBUG) { - Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]"); - } - return; - } - - errorPanelHelper.showTextError(errorString); - } - - protected void setEmptyStateMessage(@StringRes final int text) { - if (emptyStateMessageView != null) { - emptyStateMessageView.setText(text); - } - } - - public final void hideErrorPanel() { - errorPanelHelper.hide(); - lastPanelError = null; - } - - public final boolean isErrorPanelVisible() { - return errorPanelHelper.isVisible(); - } - - /** - * Directly calls {@link ErrorUtil#showSnackbar(Fragment, ErrorInfo)}, that shows a snackbar if - * a valid view can be found, otherwise creates an error report notification. - * - * @param errorInfo The error information - */ - public void showSnackBarError(final ErrorInfo errorInfo) { - if (DEBUG) { - Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]"); - } - ErrorUtil.showSnackbar(this, errorInfo); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java deleted file mode 100644 index 66e132aff..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.schabi.newpipe.fragments; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.Nullable; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorPanelHelper; - -public class BlankFragment extends BaseFragment { - - @State - @Nullable - ErrorInfo errorInfo; - @Nullable - ErrorPanelHelper errorPanel = null; - - /** - * Builds a blank fragment that just says the app name and suggests clicking on search. - */ - public BlankFragment() { - this(null); - } - - /** - * @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel. - */ - public BlankFragment(@Nullable final ErrorInfo errorInfo) { - this.errorInfo = errorInfo; - } - - @Nullable - @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, - final Bundle savedInstanceState) { - setTitle("NewPipe"); - final View view = inflater.inflate(R.layout.fragment_blank, container, false); - if (errorInfo != null) { - errorPanel = new ErrorPanelHelper(this, view, null); - errorPanel.showError(errorInfo); - view.findViewById(R.id.blank_page_content).setVisibility(View.GONE); - } - return view; - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - if (errorPanel != null) { - errorPanel.dispose(); - errorPanel = null; - } - } - - @Override - public void onResume() { - super.onResume(); - setTitle("NewPipe"); - // leave this inline. Will make it harder for copy cats. - // If you are a Copy cat FUCK YOU. - // I WILL FIND YOU, AND I WILL ... - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java deleted file mode 100644 index d4e73bcac..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.schabi.newpipe.fragments; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; - -public class EmptyFragment extends BaseFragment { - private static final String SHOW_MESSAGE = "SHOW_MESSAGE"; - - public static final EmptyFragment newInstance(final boolean showMessage) { - final EmptyFragment emptyFragment = new EmptyFragment(); - final Bundle bundle = new Bundle(1); - bundle.putBoolean(SHOW_MESSAGE, showMessage); - emptyFragment.setArguments(bundle); - return emptyFragment; - } - - @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, - final Bundle savedInstanceState) { - final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE); - final View view = inflater.inflate(R.layout.fragment_empty, container, false); - view.findViewById(R.id.empty_state_view).setVisibility( - showMessage ? View.VISIBLE : View.GONE); - return view; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java deleted file mode 100644 index 1a5e5aa45..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ /dev/null @@ -1,343 +0,0 @@ -package org.schabi.newpipe.fragments; - -import static android.widget.RelativeLayout.ABOVE; -import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM; -import static android.widget.RelativeLayout.ALIGN_PARENT_TOP; -import static android.widget.RelativeLayout.BELOW; -import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM; -import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.RelativeLayout; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; -import androidx.preference.PreferenceManager; -import androidx.viewpager.widget.ViewPager; - -import com.google.android.material.tabs.TabLayout; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentMainBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; -import org.schabi.newpipe.settings.tabs.Tab; -import org.schabi.newpipe.settings.tabs.TabsManager; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.ScrollableTabLayout; - -import java.util.ArrayList; -import java.util.List; - -public class MainFragment extends BaseFragment implements TabLayout.OnTabSelectedListener { - private FragmentMainBinding binding; - private SelectedTabsPagerAdapter pagerAdapter; - - private final List tabsList = new ArrayList<>(); - private TabsManager tabsManager; - - private boolean hasTabsChanged = false; - - private SharedPreferences prefs; - private boolean youtubeRestrictedModeEnabled; - private String youtubeRestrictedModeEnabledKey; - private boolean mainTabsPositionBottom; - private String mainTabsPositionKey; - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - tabsManager = TabsManager.getManager(activity); - tabsManager.setSavedTabsListener(() -> { - if (DEBUG) { - Log.d(TAG, "TabsManager.SavedTabsChangeListener: " - + "onTabsChanged called, isResumed = " + isResumed()); - } - if (isResumed()) { - setupTabs(); - } else { - hasTabsChanged = true; - } - }); - - prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); - youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); - mainTabsPositionKey = getString(R.string.main_tabs_position_key); - mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_main, container, false); - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - binding = FragmentMainBinding.bind(rootView); - - binding.mainTabLayout.setupWithViewPager(binding.pager); - binding.mainTabLayout.addOnTabSelectedListener(this); - - setupTabs(); - updateTabLayoutPosition(); - } - - @Override - public void onResume() { - super.onResume(); - - final boolean newYoutubeRestrictedModeEnabled = - prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); - if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) { - youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled; - setupTabs(); - } - - final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false); - if (mainTabsPositionBottom != newMainTabsPosition) { - mainTabsPositionBottom = newMainTabsPosition; - updateTabLayoutPosition(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - tabsManager.unsetSavedTabsListener(); - if (binding != null) { - binding.pager.setAdapter(null); - binding = null; - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - inflater.inflate(R.menu.menu_main_fragment, menu); - - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayHomeAsUpEnabled(false); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == R.id.action_search) { - try { - NavigationHelper.openSearchFragment(getFM(), - ServiceHelper.getSelectedServiceId(activity), ""); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening search fragment", e); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - /*////////////////////////////////////////////////////////////////////////// - // Tabs - //////////////////////////////////////////////////////////////////////////*/ - - private void setupTabs() { - tabsList.clear(); - tabsList.addAll(tabsManager.getTabs()); - - if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) { - pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), - getChildFragmentManager(), tabsList); - } - - binding.pager.setAdapter(null); - binding.pager.setAdapter(pagerAdapter); - - updateTabsIconAndDescription(); - updateTitleForTab(binding.pager.getCurrentItem()); - - hasTabsChanged = false; - } - - private void updateTabsIconAndDescription() { - for (int i = 0; i < tabsList.size(); i++) { - final TabLayout.Tab tabToSet = binding.mainTabLayout.getTabAt(i); - if (tabToSet != null) { - final Tab tab = tabsList.get(i); - tabToSet.setIcon(tab.getTabIconRes(requireContext())); - tabToSet.setContentDescription(tab.getTabName(requireContext())); - } - } - } - - private void updateTitleForTab(final int tabPosition) { - setTitle(tabsList.get(tabPosition).getTabName(requireContext())); - } - - public void commitPlaylistTabs() { - pagerAdapter.getLocalPlaylistFragments() - .stream() - .forEach(LocalPlaylistFragment::saveImmediate); - } - - private void updateTabLayoutPosition() { - final ScrollableTabLayout tabLayout = binding.mainTabLayout; - final ViewPager viewPager = binding.pager; - final boolean bottom = mainTabsPositionBottom; - - // change layout params to make the tab layout appear either at the top or at the bottom - final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams(); - final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams(); - - tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM); - tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP); - pagerParams.removeRule(bottom ? BELOW : ABOVE); - pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout); - tabLayout.setSelectedTabIndicatorGravity( - bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM); - - tabLayout.setLayoutParams(tabParams); - viewPager.setLayoutParams(pagerParams); - - // change the background and icon color of the tab layout: - // service-colored at the top, app-background-colored at the bottom - tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(), - bottom ? android.R.attr.windowBackground : R.attr.colorPrimary)); - - @ColorInt final int iconColor = bottom - ? ThemeHelper.resolveColorFromAttr(requireContext(), android.R.attr.colorAccent) - : Color.WHITE; - tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32)); - tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor)); - tabLayout.setSelectedTabIndicatorColor(iconColor); - } - - @Override - public void onTabSelected(final TabLayout.Tab selectedTab) { - if (DEBUG) { - Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]"); - } - updateTitleForTab(selectedTab.getPosition()); - } - - @Override - public void onTabUnselected(final TabLayout.Tab tab) { } - - @Override - public void onTabReselected(final TabLayout.Tab tab) { - if (DEBUG) { - Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]"); - } - updateTitleForTab(tab.getPosition()); - } - - public static final class SelectedTabsPagerAdapter - extends FragmentStatePagerAdapterMenuWorkaround { - private final Context context; - private final List internalTabsList; - /** - * Keep reference to LocalPlaylistFragments, because their data can be modified by the user - * during runtime and changes are not committed immediately. However, in some cases, - * the changes need to be committed immediately by calling - * {@link LocalPlaylistFragment#saveImmediate()}. - * The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called. - */ - private final List localPlaylistFragments = new ArrayList<>(); - - private SelectedTabsPagerAdapter(final Context context, - final FragmentManager fragmentManager, - final List tabsList) { - super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); - this.context = context; - this.internalTabsList = new ArrayList<>(tabsList); - } - - @NonNull - @Override - public Fragment getItem(final int position) { - final Tab tab = internalTabsList.get(position); - - final Fragment fragment; - try { - fragment = tab.getFragment(context); - } catch (final Throwable t) { - return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB, - "Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context))); - } - - if (fragment instanceof BaseFragment) { - ((BaseFragment) fragment).useAsFrontPage(true); - } - - if (fragment instanceof LocalPlaylistFragment) { - localPlaylistFragments.add((LocalPlaylistFragment) fragment); - } - - return fragment; - } - - public List getLocalPlaylistFragments() { - return localPlaylistFragments; - } - - @Override - public int getItemPosition(@NonNull final Object object) { - // Causes adapter to reload all Fragments when - // notifyDataSetChanged is called - return POSITION_NONE; - } - - @Override - public int getCount() { - return internalTabsList.size(); - } - - public boolean sameTabs(final List tabsToCompare) { - return internalTabsList.equals(tabsToCompare); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java deleted file mode 100644 index 6b17803c4..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.schabi.newpipe.fragments; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.StaggeredGridLayoutManager; - -/** - * Recycler view scroll listener which calls the method {@link #onScrolledDown(RecyclerView)} - * if the view is scrolled below the last item. - */ -public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener { - @Override - public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { - super.onScrolled(recyclerView, dx, dy); - if (dy > 0) { - int pastVisibleItems = 0; - final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); - - final int visibleItemCount = layoutManager.getChildCount(); - final int totalItemCount = layoutManager.getItemCount(); - - // Already covers the GridLayoutManager case - if (layoutManager instanceof LinearLayoutManager) { - pastVisibleItems = ((LinearLayoutManager) layoutManager) - .findFirstVisibleItemPosition(); - } else if (layoutManager instanceof StaggeredGridLayoutManager) { - final int[] positions = ((StaggeredGridLayoutManager) layoutManager) - .findFirstVisibleItemPositions(null); - if (positions != null && positions.length > 0) { - pastVisibleItems = positions[0]; - } - } - - if ((visibleItemCount + pastVisibleItems) >= totalItemCount) { - onScrolledDown(recyclerView); - } - } - } - - /** - * Called when the recycler view is scrolled below the last item. - * - * @param recyclerView the recycler view - */ - public abstract void onScrolledDown(RecyclerView recyclerView); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java deleted file mode 100644 index 78f644ffb..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.fragments; - -public interface ViewContract { - void showLoading(); - - void hideLoading(); - - void showEmptyState(); - - void handleResult(I result); - - void handleError(); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java deleted file mode 100644 index bd174a121..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java +++ /dev/null @@ -1,281 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; - -import android.graphics.Typeface; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.widget.TooltipCompat; -import androidx.core.text.HtmlCompat; - -import com.google.android.material.chip.Chip; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentDescriptionBinding; -import org.schabi.newpipe.databinding.ItemMetadataBinding; -import org.schabi.newpipe.databinding.ItemMetadataTagsBinding; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.text.TextLinkifier; - -import java.util.List; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public abstract class BaseDescriptionFragment extends BaseFragment { - private final CompositeDisposable descriptionDisposables = new CompositeDisposable(); - protected FragmentDescriptionBinding binding; - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentDescriptionBinding.inflate(inflater, container, false); - setupDescription(); - setupMetadata(inflater, binding.detailMetadataLayout); - addTagsMetadataItem(inflater, binding.detailMetadataLayout); - return binding.getRoot(); - } - - @Override - public void onDestroy() { - descriptionDisposables.clear(); - super.onDestroy(); - } - - /** - * Get the description to display. - * @return description object, if available - */ - @Nullable - protected abstract Description getDescription(); - - /** - * Get the streaming service. Used for generating description links. - * @return streaming service - */ - @NonNull - protected abstract StreamingService getService(); - - /** - * Get the streaming service ID. Used for tag links. - * @return service ID - */ - protected abstract int getServiceId(); - - /** - * Get the URL of the described video or audio, used to generate description links. - * @return stream URL - */ - @Nullable - protected abstract String getStreamUrl(); - - /** - * Get the list of tags to display below the description. - * @return tag list - */ - @NonNull - public abstract List getTags(); - - /** - * Add additional metadata to display. - * @param inflater LayoutInflater - * @param layout detailMetadataLayout - */ - protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout); - - private void setupDescription() { - final Description description = getDescription(); - if (description == null || isEmpty(description.getContent()) - || description == Description.EMPTY_DESCRIPTION) { - binding.detailDescriptionView.setVisibility(View.GONE); - binding.detailSelectDescriptionButton.setVisibility(View.GONE); - return; - } - - // start with disabled state. This also loads description content (!) - disableDescriptionSelection(); - - binding.detailSelectDescriptionButton.setOnClickListener(v -> { - if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { - disableDescriptionSelection(); - } else { - // enable selection only when button is clicked to prevent flickering - enableDescriptionSelection(); - } - }); - } - - private void enableDescriptionSelection() { - binding.detailDescriptionNoteView.setVisibility(View.VISIBLE); - binding.detailDescriptionView.setTextIsSelectable(true); - - final String buttonLabel = getString(R.string.description_select_disable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close); - } - - private void disableDescriptionSelection() { - // show description content again, otherwise some links are not clickable - final Description description = getDescription(); - if (description != null) { - TextLinkifier.fromDescription(binding.detailDescriptionView, - description, HtmlCompat.FROM_HTML_MODE_LEGACY, - getService(), getStreamUrl(), - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); - } - - binding.detailDescriptionNoteView.setVisibility(View.GONE); - binding.detailDescriptionView.setTextIsSelectable(false); - - final String buttonLabel = getString(R.string.description_select_enable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); - } - - protected void addMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - final boolean linkifyContent, - @StringRes final int type, - @NonNull final String content) { - if (isBlank(content)) { - return; - } - - final ItemMetadataBinding itemBinding = - ItemMetadataBinding.inflate(inflater, layout, false); - - itemBinding.metadataTypeView.setText(type); - itemBinding.metadataTypeView.setOnLongClickListener(v -> { - ShareUtils.copyToClipboard(requireContext(), content); - return true; - }); - - if (linkifyContent) { - TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); - } else { - itemBinding.metadataContentView.setText(content); - } - - itemBinding.metadataContentView.setClickable(true); - - layout.addView(itemBinding.getRoot()); - } - - private String imageSizeToText(final int heightOrWidth) { - if (heightOrWidth < 0) { - return getString(R.string.question_mark); - } else { - return String.valueOf(heightOrWidth); - } - } - - protected void addImagesMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - @StringRes final int type, - final List images) { - final String preferredImageUrl = ImageStrategy.choosePreferredImage(images); - if (preferredImageUrl == null) { - return; // null will be returned in case there is no image - } - - final ItemMetadataBinding itemBinding = - ItemMetadataBinding.inflate(inflater, layout, false); - itemBinding.metadataTypeView.setText(type); - - final SpannableStringBuilder urls = new SpannableStringBuilder(); - for (final Image image : images) { - if (urls.length() != 0) { - urls.append(", "); - } - final int entryBegin = urls.length(); - - if (image.getHeight() != Image.HEIGHT_UNKNOWN - || image.getWidth() != Image.WIDTH_UNKNOWN - // if even the resolution level is unknown, ?x? will be shown - || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { - urls.append(imageSizeToText(image.getWidth())); - urls.append('x'); - urls.append(imageSizeToText(image.getHeight())); - } else { - switch (image.getEstimatedResolutionLevel()) { - case LOW -> urls.append(getString(R.string.image_quality_low)); - case MEDIUM -> urls.append(getString(R.string.image_quality_medium)); - case HIGH -> urls.append(getString(R.string.image_quality_high)); - default -> { - // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out - } - } - } - - urls.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull final View widget) { - ShareUtils.openUrlInBrowser(requireContext(), image.getUrl()); - } - }, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - if (preferredImageUrl.equals(image.getUrl())) { - urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - - itemBinding.metadataContentView.setText(urls); - itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance()); - layout.addView(itemBinding.getRoot()); - } - - private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - final List tags = getTags(); - - if (!tags.isEmpty()) { - final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false); - - tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> { - final Chip chip = (Chip) inflater.inflate(R.layout.chip, - itemBinding.metadataTagsChips, false); - chip.setText(tag); - chip.setOnClickListener(this::onTagClick); - chip.setOnLongClickListener(this::onTagLongClick); - itemBinding.metadataTagsChips.addView(chip); - }); - - layout.addView(itemBinding.getRoot()); - } - } - - private void onTagClick(final View chip) { - if (getParentFragment() != null) { - NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(), - getServiceId(), ((Chip) chip).getText().toString()); - } - } - - private boolean onTagLongClick(final View chip) { - ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java deleted file mode 100644 index 2b0d22a32..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ /dev/null @@ -1,140 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.util.Localization.getAppLocale; - -import android.view.LayoutInflater; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.util.Localization; - -import java.util.List; - -public class DescriptionFragment extends BaseDescriptionFragment { - - @State - StreamInfo streamInfo; - - public DescriptionFragment(final StreamInfo streamInfo) { - this.streamInfo = streamInfo; - } - - public DescriptionFragment() { - // keep empty constructor for State when resuming fragment from memory - } - - - @Nullable - @Override - protected Description getDescription() { - return streamInfo.getDescription(); - } - - @NonNull - @Override - protected StreamingService getService() { - return streamInfo.getService(); - } - - @Override - protected int getServiceId() { - return streamInfo.getServiceId(); - } - - @NonNull - @Override - protected String getStreamUrl() { - return streamInfo.getUrl(); - } - - @NonNull - @Override - public List getTags() { - return streamInfo.getTags(); - } - - @Override - protected void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { - if (streamInfo != null && streamInfo.getUploadDate() != null) { - binding.detailUploadDateView.setText(Localization - .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); - } else { - binding.detailUploadDateView.setVisibility(View.GONE); - } - - if (streamInfo == null) { - return; - } - - addMetadataItem(inflater, layout, false, R.string.metadata_category, - streamInfo.getCategory()); - - addMetadataItem(inflater, layout, false, R.string.metadata_licence, - streamInfo.getLicence()); - - addPrivacyMetadataItem(inflater, layout); - - if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { - addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, - String.valueOf(streamInfo.getAgeLimit())); - } - - if (streamInfo.getLanguageInfo() != null) { - addMetadataItem(inflater, layout, false, R.string.metadata_language, - streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale())); - } - - addMetadataItem(inflater, layout, true, R.string.metadata_support, - streamInfo.getSupportInfo()); - addMetadataItem(inflater, layout, true, R.string.metadata_host, - streamInfo.getHost()); - - addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails, - streamInfo.getThumbnails()); - addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars, - streamInfo.getUploaderAvatars()); - addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars, - streamInfo.getSubChannelAvatars()); - } - - private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - if (streamInfo.getPrivacy() != null) { - @StringRes final int contentRes; - switch (streamInfo.getPrivacy()) { - case PUBLIC: - contentRes = R.string.metadata_privacy_public; - break; - case UNLISTED: - contentRes = R.string.metadata_privacy_unlisted; - break; - case PRIVATE: - contentRes = R.string.metadata_privacy_private; - break; - case INTERNAL: - contentRes = R.string.metadata_privacy_internal; - break; - case OTHER: - default: - contentRes = 0; - break; - } - - if (contentRes != 0) { - addMetadataItem(inflater, layout, false, R.string.metadata_privacy, - getString(contentRes)); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java deleted file mode 100644 index 5016a49f6..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.player.playqueue.PlayQueue; - -import java.io.Serializable; - -class StackItem implements Serializable { - private final int serviceId; - private String url; - private String title; - private PlayQueue playQueue; - - StackItem(final int serviceId, final String url, - final String title, final PlayQueue playQueue) { - this.serviceId = serviceId; - this.url = url; - this.title = title; - this.playQueue = playQueue; - } - - public void setUrl(final String url) { - this.url = url; - } - - public void setPlayQueue(final PlayQueue queue) { - this.playQueue = queue; - } - - public int getServiceId() { - return serviceId; - } - - public String getTitle() { - return title; - } - - public void setTitle(final String title) { - this.title = title; - } - - public String getUrl() { - return url; - } - - public PlayQueue getPlayQueue() { - return playQueue; - } - - @NonNull - @Override - public String toString() { - return getServiceId() + ":" + getUrl() + " > " + getTitle(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java deleted file mode 100644 index 1a11836d4..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; - -import java.util.ArrayList; -import java.util.List; - -public class TabAdapter extends FragmentPagerAdapter { - private final List mFragmentList = new ArrayList<>(); - private final List mFragmentTitleList = new ArrayList<>(); - private final FragmentManager fragmentManager; - - public TabAdapter(final FragmentManager fm) { - // if changed to BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT => crash if enqueueing stream in - // the background and then clicking on it to open VideoDetailFragment: - // "Cannot setMaxLifecycle for Fragment not attached to FragmentManager" - super(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); - this.fragmentManager = fm; - } - - @NonNull - @Override - public Fragment getItem(final int position) { - return mFragmentList.get(position); - } - - @Override - public int getCount() { - return mFragmentList.size(); - } - - public void addFragment(final Fragment fragment, final String title) { - mFragmentList.add(fragment); - mFragmentTitleList.add(title); - } - - public void clearAllItems() { - mFragmentList.clear(); - mFragmentTitleList.clear(); - } - - public void removeItem(final int position) { - mFragmentList.remove(position == 0 ? 0 : position - 1); - mFragmentTitleList.remove(position == 0 ? 0 : position - 1); - } - - public void updateItem(final int position, final Fragment fragment) { - mFragmentList.set(position, fragment); - } - - public void updateItem(final String title, final Fragment fragment) { - final int index = mFragmentTitleList.indexOf(title); - if (index != -1) { - updateItem(index, fragment); - } - } - - @Override - public int getItemPosition(@NonNull final Object object) { - if (mFragmentList.contains(object)) { - return mFragmentList.indexOf(object); - } else { - return POSITION_NONE; - } - } - - public int getItemPositionByTitle(final String title) { - return mFragmentTitleList.indexOf(title); - } - - @Nullable - public String getItemTitle(final int position) { - if (position < 0 || position >= mFragmentTitleList.size()) { - return null; - } - return mFragmentTitleList.get(position); - } - - public void notifyDataSetUpdate() { - notifyDataSetChanged(); - } - - @Override - public void destroyItem(@NonNull final ViewGroup container, - final int position, - @NonNull final Object object) { - fragmentManager.beginTransaction().remove((Fragment) object).commitNowAllowingStateLoss(); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java deleted file mode 100644 index ee93e3138..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ /dev/null @@ -1,2521 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; -import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; -import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled; -import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; -import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; - -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.database.ContentObserver; -import android.graphics.Color; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.WindowManager; -import android.view.animation.DecelerateInterpolator; -import android.widget.FrameLayout; -import android.widget.RelativeLayout; -import android.widget.Toast; - -import androidx.annotation.AttrRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.Toolbar; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.tabs.TabLayout; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.FragmentVideoDetailBinding; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.BackPressable; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.fragments.EmptyFragment; -import org.schabi.newpipe.fragments.MainFragment; -import org.schabi.newpipe.fragments.list.comments.CommentsFragment; -import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerIntentType; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.event.OnKeyDownListener; -import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.player.ui.MainPlayerUi; -import org.schabi.newpipe.player.ui.VideoPlayerUi; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.InfoCache; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; - -import coil3.util.CoilUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class VideoDetailFragment - extends BaseStateFragment - implements BackPressable, - PlayerServiceExtendedEventListener, - OnKeyDownListener { - public static final String KEY_SWITCHING_PLAYERS = "switching_players"; - - private static final float MAX_OVERLAY_ALPHA = 0.9f; - private static final float MAX_PLAYER_HEIGHT = 0.7f; - - public static final String ACTION_SHOW_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"; - public static final String ACTION_HIDE_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"; - public static final String ACTION_PLAYER_STARTED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"; - public static final String ACTION_VIDEO_FRAGMENT_RESUMED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"; - public static final String ACTION_VIDEO_FRAGMENT_STOPPED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"; - - private static final String COMMENTS_TAB_TAG = "COMMENTS"; - private static final String RELATED_TAB_TAG = "NEXT VIDEO"; - private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; - private static final String EMPTY_TAB_TAG = "EMPTY TAB"; - - // tabs - private boolean showComments; - private boolean showRelatedItems; - private boolean showDescription; - private String selectedTabTag; - @AttrRes - @NonNull - final List tabIcons = new ArrayList<>(); - @StringRes - @NonNull - final List tabContentDescriptions = new ArrayList<>(); - private boolean tabSettingsChanged = false; - private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates - - private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = - (sharedPreferences, key) -> { - if (getString(R.string.show_comments_key).equals(key)) { - showComments = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_next_video_key).equals(key)) { - showRelatedItems = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_description_key).equals(key)) { - showDescription = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } - }; - - @State - protected int serviceId = Constants.NO_SERVICE_ID; - @State - @NonNull - protected String title = ""; - @State - @Nullable - protected String url = null; - @Nullable - protected PlayQueue playQueue = null; - @State - int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - protected boolean autoPlayEnabled = true; - private boolean forceFullscreen = false; - - @Nullable - private StreamInfo currentInfo = null; - private Disposable currentWorker; - @NonNull - private final CompositeDisposable disposables = new CompositeDisposable(); - @Nullable - private Disposable positionSubscriber = null; - - private BottomSheetBehavior bottomSheetBehavior; - private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; - private BroadcastReceiver broadcastReceiver; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private FragmentVideoDetailBinding binding; - - private TabAdapter pageAdapter; - - private ContentObserver settingsContentObserver; - @Nullable - private PlayerService playerService; - private Player player; - private final PlayerHolder playerHolder = PlayerHolder.getInstance(); - - /*////////////////////////////////////////////////////////////////////////// - // Service management - //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) { - playerService = connectedPlayerService; - } - - @Override - public void onPlayerConnected(@NonNull final Player connectedPlayer, - final boolean playAfterConnect) { - player = connectedPlayer; - - // It will do nothing if the player is not in fullscreen mode - hideSystemUiIfNeeded(); - - final Optional playerUi = player.UIs().get(MainPlayerUi.class); - if (!player.videoPlayerSelected() && !playAfterConnect) { - return; - } - - if (DeviceUtils.isLandscape(requireContext())) { - // If the video is playing but orientation changed - // let's make the video in fullscreen again - checkLandscape(); - } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) - // Tablet UI has orientation-independent fullscreen - && !DeviceUtils.isTablet(activity)) { - // Device is in portrait orientation after rotation but UI is in fullscreen. - // Return back to non-fullscreen state - playerUi.ifPresent(MainPlayerUi::toggleFullscreen); - } - - if (playAfterConnect - || (currentInfo != null - && isAutoplayEnabled() - && playerUi.isEmpty())) { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayerAutoFullscreen(); - } - updateOverlayPlayQueueButtonVisibility(); - } - - @Override - public void onPlayerDisconnected() { - player = null; - // the binding could be null at this point, if the app is finishing - if (binding != null) { - restoreDefaultBrightness(); - } - } - - @Override - public void onServiceDisconnected() { - playerService = null; - } - - - /*////////////////////////////////////////////////////////////////////////*/ - - public static VideoDetailFragment getInstance(final int serviceId, - @Nullable final String url, - @NonNull final String name, - @Nullable final PlayQueue queue) { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.setInitialData(serviceId, url, name, queue); - return instance; - } - - public static VideoDetailFragment getInstanceInCollapsedState() { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); - showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); - showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); - selectedTabTag = prefs.getString( - getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); - prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); - - setupBroadcastReceiver(); - - settingsContentObserver = new ContentObserver(new Handler()) { - @Override - public void onChange(final boolean selfChange) { - if (activity != null && !globalScreenOrientationLocked(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - }; - activity.getContentResolver().registerContentObserver( - Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, - settingsContentObserver); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentVideoDetailBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onPause() { - super.onPause(); - if (currentWorker != null) { - currentWorker.dispose(); - } - restoreDefaultBrightness(); - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putString(getString(R.string.stream_info_selected_tab_key), - pageAdapter.getItemTitle(binding.viewPager.getCurrentItem())) - .apply(); - } - - @Override - public void onResume() { - super.onResume(); - if (DEBUG) { - Log.d(TAG, "onResume() called"); - } - - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); - - updateOverlayPlayQueueButtonVisibility(); - - setupBrightness(); - - if (tabSettingsChanged) { - tabSettingsChanged = false; - initTabs(); - if (currentInfo != null) { - updateTabs(currentInfo); - } - } - - // Check if it was loading when the fragment was stopped/paused - if (wasLoading.getAndSet(false) && !wasCleared()) { - startLoading(false); - } - } - - @Override - public void onStop() { - super.onStop(); - - if (!activity.isChangingConfigurations()) { - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED)); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - - // Stop the service when user leaves the app with double back press - // if video player is selected. Otherwise unbind - if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { - playerHolder.stopService(); - } else { - playerHolder.setListener(null); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - activity.unregisterReceiver(broadcastReceiver); - activity.getContentResolver().unregisterContentObserver(settingsContentObserver); - - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (currentWorker != null) { - currentWorker.dispose(); - } - disposables.clear(); - positionSubscriber = null; - currentWorker = null; - bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); - - if (activity.isFinishing()) { - playQueue = null; - currentInfo = null; - stack = new LinkedList<>(); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - switch (requestCode) { - case ReCaptchaActivity.RECAPTCHA_REQUEST: - if (resultCode == Activity.RESULT_OK) { - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - serviceId, url, title, null, false); - } else { - Log.e(TAG, "ReCaptcha failed"); - } - break; - default: - Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); - break; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OnClick - //////////////////////////////////////////////////////////////////////////*/ - - private void setOnClickListeners() { - binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); - binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - if (!isEmpty(info.getUploaderUrl())) { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - - if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL"); - } - } else { - openChannel(info.getSubChannelUrl(), info.getSubChannelName()); - } - })); - binding.detailThumbnailRootLayout.setOnClickListener(v -> { - autoPlayEnabled = true; // forcefully start playing - // FIXME Workaround #7427 - if (isPlayerAvailable()) { - player.setRecovery(); - } - openVideoPlayerAutoFullscreen(); - }); - - binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); - binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); - binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { - if (getFM() != null && currentInfo != null) { - final Fragment fragment = getParentFragmentManager(). - findFragmentById(R.id.fragment_holder); - - // commit previous pending changes to database - if (fragment instanceof LocalPlaylistFragment) { - ((LocalPlaylistFragment) fragment).saveImmediate(); - } else if (fragment instanceof MainFragment) { - ((MainFragment) fragment).commitPlaylistTabs(); - } - - disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), - List.of(new StreamEntity(info)), - dialog -> dialog.show(getParentFragmentManager(), TAG))); - } - })); - binding.detailControlsDownload.setOnClickListener(v -> { - if (PermissionHelper.checkStoragePermissions(activity, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - openDownloadDialog(); - } - }); - binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> - ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), - info.getThumbnails()))); - binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> - ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); - binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> - KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); - if (DEBUG) { - binding.detailControlsCrashThePlayer.setOnClickListener(v -> - VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); - } - - final View.OnClickListener overlayListener = v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_EXPANDED); - binding.overlayThumbnail.setOnClickListener(overlayListener); - binding.overlayMetadataLayout.setOnClickListener(overlayListener); - binding.overlayButtonsLayout.setOnClickListener(overlayListener); - binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_HIDDEN)); - binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); - binding.overlayPlayPauseButton.setOnClickListener(v -> { - if (playerIsNotStopped()) { - player.playPause(); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); - showSystemUi(); - } else { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayer(false); - } - - setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); - }); - } - - private View.OnClickListener makeOnClickListener(final Consumer consumer) { - return v -> { - if (!isLoading.get() && currentInfo != null) { - consumer.accept(currentInfo); - } - }; - } - - private void setOnLongClickListeners() { - binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> - ShareUtils.copyToClipboard(requireContext(), - binding.detailVideoTitleView.getText().toString()))); - binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); - } else { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - })); - - binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> - openBackgroundPlayer(true) - )); - binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> - openPopupPlayer(true) - )); - binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> - NavigationHelper.openDownloads(activity))); - - final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> - openChannel(info.getUploaderUrl(), info.getUploaderName())); - binding.overlayThumbnail.setOnLongClickListener(overlayListener); - binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); - } - - private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { - return v -> { - if (isLoading.get() || currentInfo == null) { - return false; - } - consumer.accept(currentInfo); - return true; - }; - } - - private void openChannel(final String subChannelUrl, final String subChannelName) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - subChannelUrl, subChannelName); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - } - - private void toggleTitleAndSecondaryControls() { - if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { - binding.detailVideoTitleView.setMaxLines(10); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); - binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); - } else { - binding.detailVideoTitleView.setMaxLines(1); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - } - // view pager height has changed, update the tab layout - updateTabLayoutVisibility(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - pageAdapter = new TabAdapter(getChildFragmentManager()); - binding.viewPager.setAdapter(pageAdapter); - binding.tabLayout.setupWithViewPager(binding.viewPager); - - binding.detailThumbnailRootLayout.requestFocus(); - - binding.detailControlsPlayWithKodi.setVisibility( - KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) - ? View.VISIBLE - : View.GONE - ); - binding.detailControlsCrashThePlayer.setVisibility( - DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) - .getBoolean(getString(R.string.show_crash_the_player_key), false) - ? View.VISIBLE - : View.GONE - ); - accommodateForTvAndDesktopMode(); - } - - @Override - @SuppressLint("ClickableViewAccessibility") - protected void initListeners() { - super.initListeners(); - - // Workaround for #5600 - // Forcefully catch click events uncaught by children because otherwise - // they will be caught by underlying view and "click through" will happen - binding.getRoot().setOnClickListener(v -> { }); - binding.getRoot().setOnLongClickListener(v -> true); - - setOnClickListeners(); - setOnLongClickListeners(); - - final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN - && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { - - animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> - animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); - } - return false; - }; - binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); - binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); - - binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { - // prevent useless updates to tab layout visibility if nothing changed - if (verticalOffset != lastAppBarVerticalOffset) { - lastAppBarVerticalOffset = verticalOffset; - // the view was scrolled - updateTabLayoutVisibility(); - } - }); - - setupBottomPlayer(); - if (!playerHolder.isBound()) { - setHeightThumbnail(); - } else { - playerHolder.startService(false, this); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OwnStack - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Stack that contains the "navigation history".
- * The peek is the current video. - */ - private static LinkedList stack = new LinkedList<>(); - - @Override - public boolean onKeyDown(final int keyCode) { - return isPlayerAvailable() - && player.UIs().get(VideoPlayerUi.class) - .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); - } - - @Override - public boolean onBackPressed() { - if (DEBUG) { - Log.d(TAG, "onBackPressed() called"); - } - - // If we are in fullscreen mode just exit from it via first back press - if (isFullscreen()) { - if (!DeviceUtils.isTablet(activity)) { - player.pause(); - } - restoreDefaultOrientation(); - setAutoPlay(false); - return true; - } - - // If we have something in history of played items we replay it here - if (isPlayerAvailable() - && player.getPlayQueue() != null - && player.videoPlayerSelected() - && player.getPlayQueue().previous()) { - return true; // no code here, as previous() was used in the if - } - - // That means that we are on the start of the stack, - if (stack.size() <= 1) { - restoreDefaultOrientation(); - return false; // let MainActivity handle the onBack (e.g. to minimize the mini player) - } - - // Remove top - stack.pop(); - // Get stack item from the new top - setupFromHistoryItem(Objects.requireNonNull(stack.peek())); - - return true; - } - - private void setupFromHistoryItem(final StackItem item) { - setAutoPlay(false); - hideMainPlayerOnLoadingNewStream(); - - setInitialData(item.getServiceId(), item.getUrl(), - item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue()); - startLoading(false); - - // Maybe an item was deleted in background activity - if (item.getPlayQueue().getItem() == null) { - return; - } - - final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); - // Update title, url, uploader from the last item in the stack (it's current now) - final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); - if (playQueueItem != null && isPlayerStopped) { - updateOverlayData(playQueueItem.getTitle(), - playQueueItem.getUploader(), playQueueItem.getThumbnails()); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Info loading and handling - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void doInitialLoadLogic() { - if (wasCleared()) { - return; - } - - if (currentInfo == null) { - prepareAndLoadInfo(); - } else { - prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50); - } - } - - public void selectAndLoadVideo(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newQueue) { - if (isPlayerAvailable() && newQueue != null && playQueue != null - && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { - // Preloading can be disabled since playback is surely being replaced. - player.disablePreloadingOfCurrentTrack(); - } - - setInitialData(newServiceId, newUrl, newTitle, newQueue); - startLoading(false, true); - } - - private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info, - final boolean scrollToTop, - final long delay) { - new Handler(Looper.getMainLooper()).postDelayed(() -> { - if (activity == null) { - return; - } - // Data can already be drawn, don't spend time twice - if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) { - return; - } - prepareAndHandleInfo(info, scrollToTop); - }, delay); - } - - private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { - if (DEBUG) { - Log.d(TAG, "prepareAndHandleInfo() called with: " - + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); - } - - showLoading(); - initTabs(); - - if (scrollToTop) { - scrollToTop(); - } - handleResult(info); - showContent(); - - } - - protected void prepareAndLoadInfo() { - scrollToTop(); - startLoading(false); - } - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - initTabs(); - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad, stack.isEmpty()); - } - - private void startLoading(final boolean forceLoad, final boolean addToBackStack) { - super.startLoading(forceLoad); - - initTabs(); - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad, addToBackStack); - } - - private void runWorker(final boolean forceLoad, final boolean addToBackStack) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - isLoading.set(false); - hideMainPlayerOnLoadingNewStream(); - if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( - getString(R.string.show_age_restricted_content), false)) { - hideAgeRestrictedContent(); - } else { - handleResult(result); - showContent(); - if (addToBackStack) { - if (playQueue == null) { - playQueue = new SinglePlayQueue(result); - } - if (stack.isEmpty() || !stack.peek().getPlayQueue() - .equalStreams(playQueue)) { - stack.push(new StackItem(serviceId, url, title, playQueue)); - } - } - - if (isAutoplayEnabled() || forceFullscreen) { - openVideoPlayerAutoFullscreen(); - } - } - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - url == null ? "no url" : url, serviceId, url))); - } - - /*////////////////////////////////////////////////////////////////////////// - // Tabs - //////////////////////////////////////////////////////////////////////////*/ - - private void initTabs() { - if (pageAdapter.getCount() != 0) { - selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()); - } - pageAdapter.clearAllItems(); - tabIcons.clear(); - tabContentDescriptions.clear(); - - if (shouldShowComments()) { - pageAdapter.addFragment( - CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG); - tabIcons.add(R.drawable.ic_comment); - tabContentDescriptions.add(R.string.comments_tab_description); - } - - if (showRelatedItems && binding.relatedItemsLayout == null) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); - tabIcons.add(R.drawable.ic_art_track); - tabContentDescriptions.add(R.string.related_items_tab_description); - } - - if (showDescription) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); - tabIcons.add(R.drawable.ic_description); - tabContentDescriptions.add(R.string.description_tab_description); - } - - if (pageAdapter.getCount() == 0) { - pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); - } - pageAdapter.notifyDataSetUpdate(); - - if (pageAdapter.getCount() >= 2) { - final int position = pageAdapter.getItemPositionByTitle(selectedTabTag); - if (position != -1) { - binding.viewPager.setCurrentItem(position); - } - updateTabIconsAndContentDescriptions(); - } - // the page adapter now contains tabs: show the tab layout - updateTabLayoutVisibility(); - } - - /** - * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in - * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content - * descriptions. This reads icons from {@link #tabIcons} and content descriptions from - * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}. - */ - private void updateTabIconsAndContentDescriptions() { - for (int i = 0; i < tabIcons.size(); ++i) { - final TabLayout.Tab tab = binding.tabLayout.getTabAt(i); - if (tab != null) { - tab.setIcon(tabIcons.get(i)); - tab.setContentDescription(tabContentDescriptions.get(i)); - } - } - } - - private void updateTabs(@NonNull final StreamInfo info) { - if (showRelatedItems) { - if (binding.relatedItemsLayout == null) { // phone - pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); - } else { // tablet + TV - getChildFragmentManager().beginTransaction() - .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) - .commitAllowingStateLoss(); - binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); - } - } - - if (showDescription) { - pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); - } - - binding.viewPager.setVisibility(View.VISIBLE); - // make sure the tab layout is visible - updateTabLayoutVisibility(); - pageAdapter.notifyDataSetUpdate(); - updateTabIconsAndContentDescriptions(); - } - - private boolean shouldShowComments() { - try { - return showComments && NewPipe.getService(serviceId) - .getServiceInfo() - .getMediaCapabilities() - .contains(COMMENTS); - } catch (final ExtractionException e) { - return false; - } - } - - public void updateTabLayoutVisibility() { - - if (binding == null) { - //If binding is null we do not need to and should not do anything with its object(s) - return; - } - - if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { - // hide tab layout if there is only one tab or if the view pager is also hidden - binding.tabLayout.setVisibility(View.GONE); - } else { - // call `post()` to be sure `viewPager.getHitRect()` - // is up to date and not being currently recomputed - binding.tabLayout.post(() -> { - final var activity = getActivity(); - if (activity != null) { - final Rect pagerHitRect = new Rect(); - binding.viewPager.getHitRect(pagerHitRect); - - final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); - final int viewPagerVisibleHeight = height - pagerHitRect.top; - // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp - final float tabLayoutHeight = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); - - if (viewPagerVisibleHeight > tabLayoutHeight * 2) { - // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 - binding.tabLayout.setTranslationY( - Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight)); - binding.tabLayout.setVisibility(View.VISIBLE); - } else { - // view pager is not visible enough - binding.tabLayout.setVisibility(View.GONE); - } - } - }); - } - } - - public void scrollToTop() { - binding.appBarLayout.setExpanded(true, true); - // notify tab layout of scrolling - updateTabLayoutVisibility(); - } - - public void scrollToComment(final CommentsInfoItem comment) { - final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG); - final Fragment fragment = pageAdapter.getItem(commentsTabPos); - if (!(fragment instanceof CommentsFragment)) { - return; - } - - // unexpand the app bar only if scrolling to the comment succeeded - if (((CommentsFragment) fragment).scrollToComment(comment)) { - binding.appBarLayout.setExpanded(false, false); - binding.viewPager.setCurrentItem(commentsTabPos, false); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Play Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void toggleFullscreenIfInFullscreenMode() { - // If a user watched video inside fullscreen mode and than chose another player - // return to non-fullscreen mode - if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { - if (playerUi.isFullscreen()) { - playerUi.toggleFullscreen(); - } - }); - } - } - - private void openBackgroundPlayer(final boolean append) { - final boolean useExternalAudioPlayer = PreferenceManager - .getDefaultSharedPreferences(activity) - .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); - - toggleFullscreenIfInFullscreenMode(); - - if (isPlayerAvailable()) { - // FIXME Workaround #7427 - player.setRecovery(); - } - - if (useExternalAudioPlayer) { - showExternalAudioPlaybackDialog(); - } else { - openNormalBackgroundPlayer(append); - } - } - - private void openPopupPlayer(final boolean append) { - if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { - return; - } - - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } else { - // FIXME Workaround #7427 - player.setRecovery(); - } - - toggleFullscreenIfInFullscreenMode(); - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { //resumePlayback: false - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnPopupPlayer(activity, queue, true)); - } - } - - /** - * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity - * is toggled to landscape orientation (which will then cause fullscreen mode). - * - * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already - * in landscape and screen orientation is locked - */ - public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) { - if (directlyFullscreenIfApplicable - && !DeviceUtils.isLandscape(requireContext()) - && PlayerHelper.globalScreenOrientationLocked(requireContext())) { - // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom - // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. - // When the activity is rotated, and its state is saved and then restored, the bottom - // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it - // doesn't tell which state it was settling to, and thus the bottom sheet settles to - // STATE_COLLAPSED. This can be solved by manually setting the state that will be - // restored (i.e. bottomSheetState) to STATE_EXPANDED. - updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); - // toggle landscape in order to open directly in fullscreen - onScreenRotationButtonClicked(); - } - - if (PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { - showExternalVideoPlaybackDialog(); - } else { - replaceQueueIfUserConfirms(this::openMainPlayer); - } - } - - /** - * If the option to start directly fullscreen is enabled, or if {@code forceFullscreen} is - * {@code true} (e.g. when switching from popup player to main player with a different video), - * calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, - * so that if the user is not already in landscape and he has screen orientation locked the - * activity rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen - * is disabled and {@code forceFullscreen} is {@code false}, calls - * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = false}, - * hence preventing it from going directly fullscreen. - * {@code forceFullscreen} is reset to {@code false} after this call. - */ - public void openVideoPlayerAutoFullscreen() { - openVideoPlayer(forceFullscreen - || PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())); - forceFullscreen = false; - } - - public void setForceFullscreen(final boolean force) { - this.forceFullscreen = force; - } - - @Nullable - public String getUrl() { - return url; - } - - private void openNormalBackgroundPlayer(final boolean append) { - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnBackgroundPlayer(activity, queue, true)); - } - } - - private void openMainPlayer() { - if (!isPlayerServiceAvailable()) { - playerHolder.startService(autoPlayEnabled, this); - return; - } - if (currentInfo == null) { - return; - } - - final PlayQueue queue = setupPlayQueueForIntent(false); - tryAddVideoPlayerView(); - - final Context context = requireContext(); - final Intent playerIntent = - NavigationHelper.getPlayerIntent(context, PlayerService.class, queue, - PlayerIntentType.AllOthers) - .putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled) - .putExtra(Player.RESUME_PLAYBACK, true); - ContextCompat.startForegroundService(activity, playerIntent); - } - - /** - * When the video detail fragment is already showing details for a video and the user opens a - * new one, the video detail fragment changes all of its old data to the new stream, so if there - * is a video player currently open it should be hidden. This method does exactly that. If - * autoplay is enabled, the underlying player is not stopped completely, since it is going to - * be reused in a few milliseconds and the flickering would be annoying. - */ - private void hideMainPlayerOnLoadingNewStream() { - final var root = getRoot(); - if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { - return; - } - - removeVideoPlayerView(); - if (isAutoplayEnabled()) { - playerService.stopForImmediateReusing(); - root.ifPresent(view -> view.setVisibility(View.GONE)); - } else { - playerHolder.stopService(); - } - } - - private PlayQueue setupPlayQueueForIntent(final boolean append) { - if (append) { - return new SinglePlayQueue(currentInfo); - } - - PlayQueue queue = playQueue; - // Size can be 0 because queue removes bad stream automatically when error occurs - if (queue == null || queue.isEmpty()) { - queue = new SinglePlayQueue(currentInfo); - } - - return queue; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - public void setAutoPlay(final boolean autoPlay) { - this.autoPlayEnabled = autoPlay; - } - - private void startOnExternalPlayer(@NonNull final Context context, - @NonNull final StreamInfo info, - @NonNull final Stream selectedStream) { - NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), - currentInfo.getSubChannelName(), selectedStream); - - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - disposables.add(recordManager.onViewed(info).onErrorComplete() - .subscribe( - ignored -> { /* successful */ }, - error -> showSnackBarError( - new ErrorInfo( - error, - UserAction.PLAY_STREAM, - "Got an error when modifying history on viewed" - ) - ) - )); - } - - private boolean isExternalPlayerEnabled() { - return PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.use_external_video_player_key), false); - } - - // This method overrides default behaviour when setAutoPlay() is called. - // Don't auto play if the user selected an external player or disabled it in settings - private boolean isAutoplayEnabled() { - return autoPlayEnabled - && !isExternalPlayerEnabled() - && (!isPlayerAvailable() || player.videoPlayerSelected()) - && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN - && PlayerHelper.isAutoplayAllowedByUser(requireContext()); - } - - private void tryAddVideoPlayerView() { - if (isPlayerAvailable() && getView() != null) { - // Setup the surface view height, so that it fits the video correctly; this is done also - // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. - setHeightThumbnail(); - } - - // do all the null checks in the posted lambda, too, since the player, the binding and the - // view could be set or unset before the lambda gets executed on the next main thread cycle - new Handler(Looper.getMainLooper()).post(() -> { - if (!isPlayerAvailable() || getView() == null) { - return; - } - - // setup the surface view height, so that it fits the video correctly - setHeightThumbnail(); - - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { - // sometimes binding would be null here, even though getView() != null above u.u - if (binding != null) { - // prevent from re-adding a view multiple times - playerUi.removeViewFromParent(); - binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); - playerUi.setupVideoSurfaceIfNeeded(); - } - }); - }); - } - - private void removeVideoPlayerView() { - makeDefaultHeightForVideoPlaceholder(); - - if (player != null) { - player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); - } - } - - private void makeDefaultHeightForVideoPlaceholder() { - if (getView() == null) { - return; - } - - binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT; - binding.playerPlaceholder.requestLayout(); - } - - private final ViewTreeObserver.OnPreDrawListener preDrawListener = - new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - - if (getView() != null) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - setHeightThumbnail(height, metrics); - getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - } - return false; - } - }; - - /** - * Method which controls the size of thumbnail and the size of main player inside - * a layout with thumbnail. It decides what height the player should have in both - * screen orientations. It knows about multiWindow feature - * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, - * {@link #MAX_PLAYER_HEIGHT}) - */ - private void setHeightThumbnail() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; - requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - - if (isFullscreen()) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - // Height is zero when the view is not yet displayed like after orientation change - if (height != 0) { - setHeightThumbnail(height, metrics); - } else { - requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener); - } - } else { - final int height = (int) (isPortrait - ? metrics.widthPixels / (16.0f / 9.0f) - : metrics.heightPixels / 2.0f); - setHeightThumbnail(height, metrics); - } - } - - private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) { - binding.detailThumbnailImageView.setLayoutParams( - new FrameLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); - binding.detailThumbnailImageView.setMinimumHeight(newHeight); - if (isPlayerAvailable()) { - final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> - ui.getBinding().surfaceView.setHeights(newHeight, - ui.isFullscreen() ? newHeight : maxHeight)); - } - } - - private void showContent() { - binding.detailContentRootHiding.setVisibility(View.VISIBLE); - } - - protected void setInitialData(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newPlayQueue) { - this.serviceId = newServiceId; - this.url = newUrl; - this.title = newTitle; - this.playQueue = newPlayQueue; - } - - private void setErrorImage(final int imageResource) { - if (binding == null || activity == null) { - return; - } - - binding.detailThumbnailImageView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), imageResource)); - animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, - 0, () -> animate(binding.detailThumbnailImageView, true, 500)); - } - - @Override - public void handleError() { - super.handleError(); - setErrorImage(R.drawable.not_available_monkey); - - if (binding.relatedItemsLayout != null) { // hide related streams for tablets - binding.relatedItemsLayout.setVisibility(View.INVISIBLE); - } - - // hide comments / related streams / description tabs - binding.viewPager.setVisibility(View.GONE); - binding.tabLayout.setVisibility(View.GONE); - } - - private void hideAgeRestrictedContent() { - showTextError(getString(R.string.restricted_video, - getString(R.string.show_age_restricted_content_title))); - } - - private void setupBroadcastReceiver() { - broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - switch (intent.getAction()) { - case ACTION_SHOW_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - break; - case ACTION_HIDE_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - break; - case ACTION_PLAYER_STARTED: - // If the state is not hidden we don't need to show the mini player - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - // Rebound to the service if it was closed via notification or mini player - if (!playerHolder.isBound()) { - playerHolder.startService( - false, VideoDetailFragment.this); - } - break; - } - } - }; - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER); - intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER); - intentFilter.addAction(ACTION_PLAYER_STARTED); - ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter, - ContextCompat.RECEIVER_EXPORTED); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Orientation listener - //////////////////////////////////////////////////////////////////////////*/ - - private void restoreDefaultOrientation() { - if (isPlayerAvailable() && player.videoPlayerSelected()) { - toggleFullscreenIfInFullscreenMode(); - } - - // This will show systemUI and pause the player. - // User can tap on Play button and video will be in fullscreen mode again - // Note for tablet: trying to avoid orientation changes since it's not easy - // to physically rotate the tablet every time - if (activity != null && !DeviceUtils.isTablet(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - - super.showLoading(); - - //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required - if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { - binding.detailContentRootHiding.setVisibility(View.INVISIBLE); - } - - animate(binding.detailThumbnailPlayButton, false, 50); - animate(binding.detailDurationView, false, 100); - binding.detailPositionView.setVisibility(View.GONE); - binding.positionView.setVisibility(View.GONE); - - binding.detailVideoTitleView.setText(title); - binding.detailVideoTitleView.setMaxLines(1); - animate(binding.detailVideoTitleView, true, 0); - - binding.detailToggleSecondaryControlsView.setVisibility(View.GONE); - binding.detailTitleRootLayout.setClickable(false); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - if (binding.relatedItemsLayout != null) { - if (showRelatedItems) { - binding.relatedItemsLayout.setVisibility( - isFullscreen() ? View.GONE : View.INVISIBLE); - } else { - binding.relatedItemsLayout.setVisibility(View.GONE); - } - } - - CoilUtils.dispose(binding.detailThumbnailImageView); - CoilUtils.dispose(binding.detailSubChannelThumbnailView); - CoilUtils.dispose(binding.overlayThumbnail); - CoilUtils.dispose(binding.detailUploaderThumbnailView); - binding.detailThumbnailImageView.setImageBitmap(null); - binding.detailSubChannelThumbnailView.setImageBitmap(null); - } - - @Override - public void handleResult(@NonNull final StreamInfo info) { - super.handleResult(info); - - currentInfo = info; - setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue); - - updateTabs(info); - - animate(binding.detailThumbnailPlayButton, true, 200); - binding.detailVideoTitleView.setText(title); - - binding.detailSubChannelThumbnailView.setVisibility(View.GONE); - - if (!isEmpty(info.getSubChannelName())) { - displayBothUploaderAndSubChannel(info); - } else { - displayUploaderAsSubChannel(info); - } - - if (info.getViewCount() >= 0) { - if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization.listeningCount(activity, - info.getViewCount())); - } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization - .localizeWatchingCount(activity, info.getViewCount())); - } else { - binding.detailViewCountView.setText(Localization - .localizeViewCount(activity, info.getViewCount())); - } - binding.detailViewCountView.setVisibility(View.VISIBLE); - } else { - binding.detailViewCountView.setVisibility(View.GONE); - } - - if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsDownCountView.setVisibility(View.GONE); - - binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); - } else { - if (info.getDislikeCount() >= 0) { - binding.detailThumbsDownCountView.setText(Localization - .shortCount(activity, info.getDislikeCount())); - binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsDownCountView.setVisibility(View.GONE); - binding.detailThumbsDownImgView.setVisibility(View.GONE); - } - - if (info.getLikeCount() >= 0) { - binding.detailThumbsUpCountView.setText(Localization.shortCount(activity, - info.getLikeCount())); - binding.detailThumbsUpCountView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsUpImgView.setVisibility(View.GONE); - } - binding.detailThumbsDisabledView.setVisibility(View.GONE); - } - - if (info.getDuration() > 0) { - binding.detailDurationView.setText(Localization.getDurationString(info.getDuration())); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else if (info.getStreamType() == StreamType.LIVE_STREAM) { - binding.detailDurationView.setText(R.string.duration_live); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.live_duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else { - binding.detailDurationView.setVisibility(View.GONE); - } - - binding.detailTitleRootLayout.setClickable(true); - binding.detailToggleSecondaryControlsView.setRotation(0); - binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - checkUpdateProgressInfo(info); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView, - info.getThumbnails()); - showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator, disposables); - - if (!isPlayerAvailable() || player.isStopped()) { - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - } - - if (!info.getErrors().isEmpty()) { - // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is - // thrown. This is not an error and thus should not be shown to the user. - for (final Throwable throwable : info.getErrors()) { - if (throwable instanceof ContentNotSupportedException - && "Fan pages are not supported".equals(throwable.getMessage())) { - info.getErrors().remove(throwable); - } - } - - if (!info.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM, - "Some info not extracted: " + info.getUrl(), info)); - } - } - - binding.detailControlsDownload.setVisibility( - StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); - binding.detailControlsBackground.setVisibility( - info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() - ? View.GONE : View.VISIBLE); - - final boolean noVideoStreams = - info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); - binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE); - binding.detailThumbnailPlayButton.setImageResource( - noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); - } - - private void displayUploaderAsSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getUploaderName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - if (info.getUploaderSubscriberCount() > -1) { - binding.detailUploaderTextView.setText( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getUploaderAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - binding.detailUploaderThumbnailView.setVisibility(View.GONE); - } - - private void displayBothUploaderAndSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getSubChannelName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - final StringBuilder subText = new StringBuilder(); - if (!isEmpty(info.getUploaderName())) { - subText.append( - String.format(getString(R.string.video_detail_by), info.getUploaderName())); - } - if (info.getUploaderSubscriberCount() > -1) { - if (subText.length() > 0) { - subText.append(Localization.DOT_SEPARATOR); - } - subText.append( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - } - - if (subText.length() > 0) { - binding.detailUploaderTextView.setText(subText); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - binding.detailUploaderTextView.setSelected(true); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getSubChannelAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView, - info.getUploaderAvatars()); - binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); - } - - public void openDownloadDialog() { - if (currentInfo == null) { - return; - } - - try { - final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); - } catch (final Exception e) { - ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, - "Showing download dialog", currentInfo)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Stream Results - //////////////////////////////////////////////////////////////////////////*/ - - private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (!getResumePlaybackEnabled(activity)) { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - return; - } - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - positionSubscriber = recordManager.loadStreamState(info) - .subscribeOn(Schedulers.io()) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(state -> { - updatePlaybackProgress( - state.getProgressMillis(), info.getDuration() * 1000); - }, e -> { - // impossible since the onErrorComplete() - }, () -> { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - }); - } - - private void updatePlaybackProgress(final long progress, final long duration) { - if (!getResumePlaybackEnabled(activity)) { - return; - } - final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); - final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); - // If the old and the new progress values have a big difference then use animation. - // Otherwise don't because it affects CPU - final int progressDifference = Math.abs(binding.positionView.getProgress() - - progressSeconds); - binding.positionView.setMax(durationSeconds); - if (progressDifference > 2) { - binding.positionView.setProgressAnimated(progressSeconds); - } else { - binding.positionView.setProgress(progressSeconds); - } - final String position = Localization.getDurationString(progressSeconds); - if (position != binding.detailPositionView.getText()) { - binding.detailPositionView.setText(position); - } - if (binding.positionView.getVisibility() != View.VISIBLE) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Player event listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onViewCreated() { - tryAddVideoPlayerView(); - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - playQueue = queue; - if (DEBUG) { - Log.d(TAG, "onQueueUpdate() called with: serviceId = [" - + serviceId + "], url = [" + url + "], name = [" - + title + "], playQueue = [" + playQueue + "]"); - } - - // Register broadcast receiver to listen to playQueue changes - // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. - if (playQueue != null && playQueue.getBroadcastReceiver() != null) { - playQueue.getBroadcastReceiver().subscribe( - event -> updateOverlayPlayQueueButtonVisibility() - ); - } - - // This should be the only place where we push data to stack. - // It will allow to have live instance of PlayQueue with actual information about - // deleted/added items inside Channel/Playlist queue and makes possible to have - // a history of played items - @Nullable final StackItem stackPeek = stack.peek(); - if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { - @Nullable final PlayQueueItem playQueueItem = queue.getItem(); - if (playQueueItem != null) { - stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), - playQueueItem.getTitle(), queue)); - return; - } // else continue below - } - - @Nullable final StackItem stackWithQueue = findQueueInStack(queue); - if (stackWithQueue != null) { - // On every MainPlayer service's destroy() playQueue gets disposed and - // no longer able to track progress. That's why we update our cached disposed - // queue with the new one that is active and have the same history. - // Without that the cached playQueue will have an old recovery position - stackWithQueue.setPlayQueue(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - setOverlayPlayPauseImage(player != null && player.isPlaying()); - - switch (state) { - case Player.STATE_PLAYING: - if (binding.positionView.getAlpha() != 1.0f - && player.getPlayQueue() != null - && player.getPlayQueue().getItem() != null - && player.getPlayQueue().getItem().getUrl().equals(url)) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - break; - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - // Progress updates every second even if media is paused. It's useless until playing - if (!player.isPlaying() || playQueue == null) { - return; - } - - if (player.getPlayQueue().getItem().getUrl().equals(url)) { - updatePlaybackProgress(currentProgress, duration); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - final StackItem item = findQueueInStack(queue); - if (item != null) { - // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) - // every new played stream gives new title and url. - // StackItem contains information about first played stream. Let's update it here - item.setTitle(info.getName()); - item.setUrl(info.getUrl()); - } - // They are not equal when user watches something in popup while browsing in fragment and - // then changes screen orientation. In that case the fragment will set itself as - // a service listener and will receive initial call to onMetadataUpdate() - if (!queue.equalStreams(playQueue)) { - return; - } - - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { - return; - } - - currentInfo = info; - setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue); - setAutoPlay(false); - // Delay execution just because it freezes the main thread, and while playing - // next/previous video you see visual glitches - // (when non-vertical video goes after vertical video) - prepareAndHandleInfoIfNeededAfterDelay(info, true, 200); - } - - @Override - public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { - if (!isCatchableException) { - // Properly exit from fullscreen - toggleFullscreenIfInFullscreenMode(); - hideMainPlayerOnLoadingNewStream(); - } - } - - @Override - public void onServiceStopped() { - // the binding could be null at this point, if the app is finishing - if (binding != null) { - setOverlayPlayPauseImage(false); - if (currentInfo != null) { - updateOverlayData(currentInfo.getName(), - currentInfo.getUploaderName(), - currentInfo.getThumbnails()); - } - updateOverlayPlayQueueButtonVisibility(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - setupBrightness(); - if (!isPlayerAndPlayerServiceAvailable() - || player.UIs().get(MainPlayerUi.class).isEmpty() - || getRoot().map(View::getParent).isEmpty()) { - return; - } - - if (fullscreen) { - hideSystemUiIfNeeded(); - binding.overlayPlayPauseButton.requestFocus(); - } else { - showSystemUi(); - } - - if (binding.relatedItemsLayout != null) { - if (showRelatedItems) { - binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); - } else { - binding.relatedItemsLayout.setVisibility(View.GONE); - } - } - scrollToTop(); - - tryAddVideoPlayerView(); - } - - @Override - public void onScreenRotationButtonClicked() { - // On Android TV screen rotation is not supported - // In tablet user experience will be better if screen will not be rotated - // from landscape to portrait every time. - // Just turn on fullscreen mode in landscape orientation - // or portrait & unlocked global orientation - final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); - if (DeviceUtils.isTv(activity) || DeviceUtils.isTablet(activity) - && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); - return; - } - - final int newOrientation = isLandscape - ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; - - activity.setRequestedOrientation(newOrientation); - } - - /* - * Will scroll down to description view after long click on moreOptionsButton - * */ - @Override - public void onMoreOptionsLongClicked() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - final ValueAnimator valueAnimator = ValueAnimator - .ofInt(0, -binding.playerPlaceholder.getHeight()); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.addUpdateListener(animation -> { - behavior.setTopAndBottomOffset((int) animation.getAnimatedValue()); - binding.appBarLayout.requestLayout(); - }); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.setDuration(500); - valueAnimator.start(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Player related utils - //////////////////////////////////////////////////////////////////////////*/ - - private void showSystemUi() { - if (DEBUG) { - Log.d(TAG, "showSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; - } - activity.getWindow().getDecorView().setSystemUiVisibility(0); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( - requireContext(), android.R.attr.colorPrimary)); - } - - private void hideSystemUi() { - if (DEBUG) { - Log.d(TAG, "hideSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; - - // In multiWindow mode status bar is not transparent for devices with cutout - // if I include this flag. So without it is better in this case - final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); - if (!isInMultiWindow) { - visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; - } - activity.getWindow().getDecorView().setSystemUiVisibility(visibility); - - if (isInMultiWindow || isFullscreen()) { - activity.getWindow().setStatusBarColor(Color.TRANSPARENT); - activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); - } - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - // Listener implementation - @Override - public void hideSystemUiIfNeeded() { - if (isFullscreen() - && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - hideSystemUi(); - } - } - - private boolean isFullscreen() { - return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) - .map(VideoPlayerUi::isFullscreen).orElse(false); - } - - private boolean playerIsNotStopped() { - return isPlayerAvailable() && !player.isStopped(); - } - - private void restoreDefaultBrightness() { - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (lp.screenBrightness == -1) { - return; - } - - // Restore the old brightness when fragment.onPause() called or - // when a player is in portrait - lp.screenBrightness = -1; - activity.getWindow().setAttributes(lp); - } - - private void setupBrightness() { - if (activity == null) { - return; - } - - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { - // Apply system brightness when the player is not in fullscreen - restoreDefaultBrightness(); - } else { - // Do not restore if user has disabled brightness gesture - if (!PlayerHelper.getActionForRightGestureSide(activity) - .equals(getString(R.string.brightness_control_key)) - && !PlayerHelper.getActionForLeftGestureSide(activity) - .equals(getString(R.string.brightness_control_key))) { - return; - } - // Restore already saved brightness level - final float brightnessLevel = PlayerHelper.getScreenBrightness(activity); - if (brightnessLevel == lp.screenBrightness) { - return; - } - lp.screenBrightness = brightnessLevel; - activity.getWindow().setAttributes(lp); - } - } - - /** - * Make changes to the UI to accommodate for better usability on bigger screens such as TVs - * or in Android's desktop mode (DeX etc). - */ - private void accommodateForTvAndDesktopMode() { - if (DeviceUtils.isTv(getContext())) { - // remove ripple effects from detail controls - final int transparent = ContextCompat.getColor(requireContext(), - R.color.transparent_background_color); - binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); - binding.detailControlsBackground.setBackgroundColor(transparent); - binding.detailControlsPopup.setBackgroundColor(transparent); - binding.detailControlsDownload.setBackgroundColor(transparent); - binding.detailControlsShare.setBackgroundColor(transparent); - binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); - binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); - } - if (DeviceUtils.isDesktopMode(getContext())) { - // Remove the "hover" overlay (since it is visible on all mouse events and interferes - // with the video content being played) - binding.detailThumbnailRootLayout.setForeground(null); - } - } - - private void checkLandscape() { - if ((!player.isPlaying() && player.getPlayQueue() != playQueue) - || player.getPlayQueue() == null) { - setAutoPlay(true); - } - - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); - // Let's give a user time to look at video information page if video is not playing - if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { - player.play(); - } - } - - /* - * Means that the player fragment was swiped away via BottomSheetLayout - * and is empty but ready for any new actions. See cleanUp() - * */ - private boolean wasCleared() { - return url == null; - } - - @Nullable - private StackItem findQueueInStack(final PlayQueue queue) { - StackItem item = null; - final Iterator iterator = stack.descendingIterator(); - while (iterator.hasNext()) { - final StackItem next = iterator.next(); - if (next.getPlayQueue().equalStreams(queue)) { - item = next; - break; - } - } - return item; - } - - private void replaceQueueIfUserConfirms(final Runnable onAllow) { - @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; - - // Player will have STATE_IDLE when a user pressed back button - if (isClearingQueueConfirmationRequired(activity) - && playerIsNotStopped() - && activeQueue != null - && !activeQueue.equalStreams(playQueue)) { - showClearingQueueConfirmation(onAllow); - } else { - onAllow.run(); - } - } - - private void showClearingQueueConfirmation(final Runnable onAllow) { - new AlertDialog.Builder(activity) - .setTitle(R.string.clear_queue_confirmation_description) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> { - onAllow.run(); - dialog.dismiss(); - }) - .show(); - } - - private void showExternalVideoPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(R.string.select_quality_external_players); - builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)); - - final List videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList( - activity, - getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), - getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), - false, - false - ); - - if (videoStreamsForExternalPlayers.isEmpty()) { - builder.setMessage(R.string.no_video_streams_available_for_external_players); - builder.setPositiveButton(R.string.ok, null); - - } else { - final int selectedVideoStreamIndexForExternalPlayers = - ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); - final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() - .map(VideoStream::getResolution).toArray(CharSequence[]::new); - - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, - null); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); - // We don't have to manage the index validity because if there is no stream - // available for external players, this code will be not executed and if there is - // no stream which matches the default resolution, 0 is returned by - // ListHelper.getDefaultResolutionIndex. - // The index cannot be outside the bounds of the list as its always between 0 and - // the list size - 1, . - startOnExternalPlayer(activity, currentInfo, - videoStreamsForExternalPlayers.get(index)); - }); - } - builder.show(); - } - - private void showExternalAudioPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final List audioStreams = getUrlAndNonTorrentStreams( - currentInfo.getAudioStreams()); - final List audioTracks = - ListHelper.getFilteredAudioStreams(activity, audioStreams); - - if (audioTracks.isEmpty()) { - Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, - Toast.LENGTH_SHORT).show(); - } else if (audioTracks.size() == 1) { - startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); - } else { - final int selectedAudioStream = - ListHelper.getDefaultAudioFormat(activity, audioTracks); - final CharSequence[] trackNames = audioTracks.stream() - .map(audioStream -> Localization.audioTrackName(activity, audioStream)) - .toArray(CharSequence[]::new); - - new AlertDialog.Builder(activity) - .setTitle(R.string.select_audio_track_external_players) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)) - .setSingleChoiceItems(trackNames, selectedAudioStream, null) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView() - .getCheckedItemPosition(); - startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); - }) - .show(); - } - } - - /* - * Remove unneeded information while waiting for a next task - * */ - private void cleanUp() { - // New beginning - stack.clear(); - if (currentWorker != null) { - currentWorker.dispose(); - } - playerHolder.stopService(); - setInitialData(0, null, "", null); - currentInfo = null; - updateOverlayData(null, null, List.of()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Bottom mini player - //////////////////////////////////////////////////////////////////////////*/ - - /** - * That's for Android TV support. Move focus from main fragment to the player or back - * based on what is currently selected - * - * @param toMain if true than the main fragment will be focused or the player otherwise - */ - private void moveFocusToMainFragment(final boolean toMain) { - setupBrightness(); - final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder); - // Hamburger button steels a focus even under bottomSheet - final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); - final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS; - final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS; - if (toMain) { - mainFragment.setDescendantFocusability(afterDescendants); - toolbar.setDescendantFocusability(afterDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants); - // Only focus the mainFragment if the mainFragment (e.g. search-results) - // or the toolbar (e.g. Textfield for search) don't have focus. - // This was done to fix problems with the keyboard input, see also #7490 - if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { - mainFragment.requestFocus(); - } - } else { - mainFragment.setDescendantFocusability(blockDescendants); - toolbar.setDescendantFocusability(blockDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants); - // Only focus the player if it not already has focus - if (!binding.getRoot().hasFocus()) { - binding.detailThumbnailRootLayout.requestFocus(); - } - } - } - - /** - * When the mini player exists the view underneath it is not touchable. - * Bottom padding should be equal to the mini player's height in this case - * - * @param showMore whether main fragment should be expanded or not - */ - private void manageSpaceAtTheBottom(final boolean showMore) { - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder); - final int newBottomPadding; - if (showMore) { - newBottomPadding = 0; - } else { - newBottomPadding = peekHeight; - } - if (holder.getPaddingBottom() == newBottomPadding) { - return; - } - holder.setPadding(holder.getPaddingLeft(), - holder.getPaddingTop(), - holder.getPaddingRight(), - newBottomPadding); - } - - private void setupBottomPlayer() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - - final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); - bottomSheetBehavior.setState(lastStableBottomSheetState); - updateBottomSheetState(lastStableBottomSheetState); - - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { - manageSpaceAtTheBottom(false); - bottomSheetBehavior.setPeekHeight(peekHeight); - if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { - binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA); - } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { - binding.overlayLayout.setAlpha(0); - setOverlayElementsClickable(false); - } - } - - bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull final View bottomSheet, final int newState) { - updateBottomSheetState(newState); - - switch (newState) { - case BottomSheetBehavior.STATE_HIDDEN: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(true); - - bottomSheetBehavior.setPeekHeight(0); - cleanUp(); - break; - case BottomSheetBehavior.STATE_EXPANDED: - moveFocusToMainFragment(false); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - // Disable click because overlay buttons located on top of buttons - // from the player - setOverlayElementsClickable(false); - hideSystemUiIfNeeded(); - // Conditions when the player should be expanded to fullscreen - if (DeviceUtils.isLandscape(requireContext()) - && isPlayerAvailable() - && player.isPlaying() - && !isFullscreen() - && !DeviceUtils.isTablet(activity)) { - player.UIs().get(MainPlayerUi.class) - .ifPresent(MainPlayerUi::toggleFullscreen); - } - setOverlayLook(binding.appBarLayout, behavior, 1); - break; - case BottomSheetBehavior.STATE_COLLAPSED: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - - // Re-enable clicks - setOverlayElementsClickable(true); - if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class) - .ifPresent(MainPlayerUi::closeItemsList); - } - setOverlayLook(binding.appBarLayout, behavior, 0); - break; - case BottomSheetBehavior.STATE_DRAGGING: - case BottomSheetBehavior.STATE_SETTLING: - if (isFullscreen()) { - showSystemUi(); - } - if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { - if (ui.isControlsVisible()) { - ui.hideControls(0, 0); - } - }); - } - break; - case BottomSheetBehavior.STATE_HALF_EXPANDED: - break; - } - } - - @Override - public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { - setOverlayLook(binding.appBarLayout, behavior, slideOffset); - } - }; - - bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); - - // User opened a new page and the player will hide itself - activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - }); - } - - private void updateOverlayPlayQueueButtonVisibility() { - final boolean isPlayQueueEmpty = - player == null // no player => no play queue :) - || player.getPlayQueue() == null - || player.getPlayQueue().isEmpty(); - if (binding != null) { - // binding is null when rotating the device... - binding.overlayPlayQueueButton.setVisibility( - isPlayQueueEmpty ? View.GONE : View.VISIBLE); - } - } - - private void updateOverlayData(@Nullable final String overlayTitle, - @Nullable final String uploader, - @NonNull final List thumbnails) { - binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); - binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); - binding.overlayThumbnail.setImageDrawable(null); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails); - } - - private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { - final int drawable = playerIsPlaying - ? R.drawable.ic_pause - : R.drawable.ic_play_arrow; - binding.overlayPlayPauseButton.setImageResource(drawable); - } - - private void setOverlayLook(final AppBarLayout appBar, - final AppBarLayout.Behavior behavior, - final float slideOffset) { - // SlideOffset < 0 when mini player is about to close via swipe. - // Stop animation in this case - if (behavior == null || slideOffset < 0) { - return; - } - binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset)); - // These numbers are not special. They just do a cool transition - behavior.setTopAndBottomOffset( - (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3)); - appBar.requestLayout(); - } - - private void setOverlayElementsClickable(final boolean enable) { - binding.overlayThumbnail.setClickable(enable); - binding.overlayThumbnail.setLongClickable(enable); - binding.overlayMetadataLayout.setClickable(enable); - binding.overlayMetadataLayout.setLongClickable(enable); - binding.overlayButtonsLayout.setClickable(enable); - binding.overlayPlayQueueButton.setClickable(enable); - binding.overlayPlayPauseButton.setClickable(enable); - binding.overlayCloseButton.setClickable(enable); - } - - // helpers to check the state of player and playerService - boolean isPlayerAvailable() { - return player != null; - } - - boolean isPlayerServiceAvailable() { - return playerService != null; - } - - boolean isPlayerAndPlayerServiceAvailable() { - return player != null && playerService != null; - } - - public Optional getRoot() { - return Optional.ofNullable(player) - .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) - .map(playerUi -> playerUi.getBinding().getRoot()); - } - - private void updateBottomSheetState(final int newState) { - bottomSheetState = newState; - if (newState != BottomSheetBehavior.STATE_DRAGGING - && newState != BottomSheetBehavior.STATE_SETTLING) { - lastStableBottomSheetState = newState; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java deleted file mode 100644 index c816723ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java +++ /dev/null @@ -1,146 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; - -import android.content.Context; -import android.util.Log; -import android.util.Pair; -import android.view.ContextThemeWrapper; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.PlaybackException; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ListRadioIconItemBinding; -import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.util.ThemeHelper; - -import java.io.IOException; -import java.util.List; -import java.util.function.Supplier; - -/** - * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. - */ -public final class VideoDetailPlayerCrasher { - - // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) - // or it fails with an IllegalArgumentException - // https://stackoverflow.com/a/54744028 - private static final String TAG = "VideoDetPlayerCrasher"; - - private static final String DEFAULT_MSG = "Dummy"; - - private static final List>> - AVAILABLE_EXCEPTION_TYPES = List.of( - new Pair<>("Source", () -> ExoPlaybackException.createForSource( - new IOException(DEFAULT_MSG), - ERROR_CODE_BEHIND_LIVE_WINDOW - )), - new Pair<>("Renderer", () -> ExoPlaybackException.createForRenderer( - new Exception(DEFAULT_MSG), - "Dummy renderer", - 0, - null, - C.FORMAT_HANDLED, - /*isRecoverable=*/false, - ERROR_CODE_DECODING_FAILED - )), - new Pair<>("Unexpected", () -> ExoPlaybackException.createForUnexpected( - new RuntimeException(DEFAULT_MSG), - ERROR_CODE_UNSPECIFIED - )), - new Pair<>("Remote", () -> ExoPlaybackException.createForRemote(DEFAULT_MSG)) - ); - - private VideoDetailPlayerCrasher() { - // No impls - } - - private static Context getThemeWrapperContext(final Context context) { - return new ContextThemeWrapper( - context, - ThemeHelper.isLightThemeSelected(context) - ? R.style.LightTheme - : R.style.DarkTheme); - } - - public static void onCrashThePlayer( - @NonNull final Context context, - @Nullable final Player player - ) { - if (player == null) { - Log.d(TAG, "Player is not available"); - Toast.makeText(context, "Player is not available", Toast.LENGTH_SHORT) - .show(); - - return; - } - - // -- Build the dialog/UI -- - final Context themeWrapperContext = getThemeWrapperContext(context); - final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); - - final SingleChoiceDialogViewBinding binding = - SingleChoiceDialogViewBinding.inflate(inflater); - - final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) - .setTitle("Choose an exception") - .setView(binding.getRoot()) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .create(); - - for (final Pair> entry : AVAILABLE_EXCEPTION_TYPES) { - final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); - radioButton.setText(entry.first); - radioButton.setChecked(false); - radioButton.setLayoutParams( - new RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ); - radioButton.setOnClickListener(v -> { - tryCrashPlayerWith(player, entry.second.get()); - alertDialog.cancel(); - }); - binding.list.addView(radioButton); - } - - alertDialog.show(); - } - - /** - * Note that this method does not crash the underlying exoplayer directly (it's not possible). - * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}. - * @param player - * @param exception - */ - private static void tryCrashPlayerWith( - @NonNull final Player player, - @NonNull final ExoPlaybackException exception - ) { - Log.d(TAG, "Crashing the player using player.onPlayerError(ex)"); - try { - player.onPlayerError(exception); - } catch (final Exception exPlayer) { - Log.e(TAG, - "Run into an exception while crashing the player:", - exPlayer); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java deleted file mode 100644 index 8a117a47a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ /dev/null @@ -1,489 +0,0 @@ -package org.schabi.newpipe.fragments.list; - -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.info_list.InfoListAdapter; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.SuperScrollLayoutManager; - -import java.util.List; -import java.util.Queue; -import java.util.function.Supplier; - -public abstract class BaseListFragment extends BaseStateFragment - implements ListViewContract, StateSaver.WriteRead, - SharedPreferences.OnSharedPreferenceChangeListener { - private static final int LIST_MODE_UPDATE_FLAG = 0x32; - protected org.schabi.newpipe.util.SavedState savedState; - - private boolean useDefaultStateSaving = true; - private int updateFlags = 0; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - protected InfoListAdapter infoListAdapter; - protected RecyclerView itemsList; - private int focusedPosition = -1; - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - - if (infoListAdapter == null) { - infoListAdapter = new InfoListAdapter(activity); - } - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - PreferenceManager.getDefaultSharedPreferences(activity) - .registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (useDefaultStateSaving) { - StateSaver.onDestroy(savedState); - } - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(this); - } - - @Override - public void onResume() { - super.onResume(); - - if (updateFlags != 0) { - if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - refreshItemViewMode(); - } - updateFlags = 0; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - /** - * If the default implementation of {@link StateSaver.WriteRead} should be used. - * - * @param useDefaultStateSaving Whether the default implementation should be used - * @see StateSaver - */ - public void setUseDefaultStateSaving(final boolean useDefaultStateSaving) { - this.useDefaultStateSaving = useDefaultStateSaving; - } - - @Override - public String generateSuffix() { - // Naive solution, but it's good for now (the items don't change) - return "." + infoListAdapter.getItemsList().size() + ".list"; - } - - private int getFocusedPosition() { - try { - final View focusedItem = itemsList.getFocusedChild(); - final RecyclerView.ViewHolder itemHolder = - itemsList.findContainingViewHolder(focusedItem); - return itemHolder.getBindingAdapterPosition(); - } catch (final NullPointerException e) { - return -1; - } - } - - @Override - public void writeTo(final Queue objectsToSave) { - if (!useDefaultStateSaving) { - return; - } - - objectsToSave.add(infoListAdapter.getItemsList()); - objectsToSave.add(getFocusedPosition()); - } - - @Override - @SuppressWarnings("unchecked") - public void readFrom(@NonNull final Queue savedObjects) throws Exception { - if (!useDefaultStateSaving) { - return; - } - - infoListAdapter.getItemsList().clear(); - infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); - restoreFocus((Integer) savedObjects.poll()); - } - - private void restoreFocus(final Integer position) { - if (position == null || position < 0) { - return; - } - - itemsList.post(() -> { - final RecyclerView.ViewHolder focusedHolder = - itemsList.findViewHolderForAdapterPosition(position); - - if (focusedHolder != null) { - focusedHolder.itemView.requestFocus(); - } - }); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle bundle) { - super.onSaveInstanceState(bundle); - if (useDefaultStateSaving) { - savedState = StateSaver - .tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); - } - } - - @Override - protected void onRestoreInstanceState(@NonNull final Bundle bundle) { - super.onRestoreInstanceState(bundle); - if (useDefaultStateSaving) { - savedState = StateSaver.tryToRestore(bundle, this); - } - } - - @Override - public void onStop() { - focusedPosition = getFocusedPosition(); - super.onStop(); - } - - @Override - public void onStart() { - super.onStart(); - restoreFocus(focusedPosition); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - protected Supplier getListHeaderSupplier() { - return null; - } - - protected RecyclerView.LayoutManager getListLayoutManager() { - return new SuperScrollLayoutManager(activity); - } - - protected RecyclerView.LayoutManager getGridLayoutManager() { - final Resources resources = activity.getResources(); - int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); - width += (24 * resources.getDisplayMetrics().density); - final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); - final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); - lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); - return lm; - } - - /** - * Updates the item view mode based on user preference. - */ - private void refreshItemViewMode() { - final ItemViewMode itemViewMode = getItemViewMode(); - itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) - ? getGridLayoutManager() : getListLayoutManager()); - infoListAdapter.setItemViewMode(itemViewMode); - infoListAdapter.notifyDataSetChanged(); - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - itemsList = rootView.findViewById(R.id.items_list); - refreshItemViewMode(); - - final Supplier listHeaderSupplier = getListHeaderSupplier(); - if (listHeaderSupplier != null) { - infoListAdapter.setHeaderSupplier(listHeaderSupplier); - } - - itemsList.setAdapter(infoListAdapter); - } - - protected void onItemSelected(final InfoItem selectedItem) { - if (DEBUG) { - Log.d(TAG, "onItemSelected() called with: selectedItem = [" + selectedItem + "]"); - } - } - - @Override - protected void initListeners() { - super.initListeners(); - infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final StreamInfoItem selectedItem) { - onStreamSelected(selectedItem); - } - - @Override - public void held(final StreamInfoItem selectedItem) { - showInfoItemDialog(selectedItem); - } - }); - - infoListAdapter.setOnChannelSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - }); - - infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); - } - }); - - infoListAdapter.setOnCommentsSelectedListener(this::onItemSelected); - - // Ensure that there is always a scroll listener (e.g. when rotating the device) - useNormalItemListScrollListener(); - } - - /** - * Removes all listeners and adds the normal scroll listener to the {@link #itemsList}. - */ - protected void useNormalItemListScrollListener() { - if (DEBUG) { - Log.d(TAG, "useNormalItemListScrollListener called"); - } - itemsList.clearOnScrollListeners(); - itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener()); - } - - /** - * Removes all listeners and adds the initial scroll listener to the {@link #itemsList}. - *
- * Which tries to load more items when not enough are in the view (not scrollable) - * and more are available. - *
- * Note: This method only works because "This callback will also be called if visible - * item range changes after a layout calculation. In that case, dx and dy will be 0." - * - which might be unexpected because no actual scrolling occurs... - *
- * This listener will be replaced by DefaultItemListOnScrolledDownListener when - *
    - *
  • the view was actually scrolled
  • - *
  • the view is scrollable
  • - *
  • no more items can be loaded
  • - *
- */ - protected void useInitialItemListLoadScrollListener() { - if (DEBUG) { - Log.d(TAG, "useInitialItemListLoadScrollListener called"); - } - itemsList.clearOnScrollListeners(); - itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() { - @Override - public void onScrolled(@NonNull final RecyclerView recyclerView, - final int dx, final int dy) { - super.onScrolled(recyclerView, dx, dy); - - if (dy != 0) { - log("Vertical scroll occurred"); - - useNormalItemListScrollListener(); - return; - } - if (isLoading.get()) { - log("Still loading data -> Skipping"); - return; - } - if (!hasMoreItems()) { - log("No more items to load"); - - useNormalItemListScrollListener(); - return; - } - if (itemsList.canScrollVertically(1) - || itemsList.canScrollVertically(-1)) { - log("View is scrollable"); - - useNormalItemListScrollListener(); - return; - } - - log("Loading more data"); - loadMoreItems(); - } - - private void log(final String msg) { - if (DEBUG) { - Log.d(TAG, "initItemListLoadScrollListener - " + msg); - } - } - }); - } - - class DefaultItemListOnScrolledDownListener extends OnScrollBelowItemsListener { - @Override - public void onScrolledDown(final RecyclerView recyclerView) { - onScrollToBottom(); - } - } - - private void onStreamSelected(final StreamInfoItem selectedItem) { - onItemSelected(selectedItem); - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), - null, false); - } - - protected void onScrollToBottom() { - if (hasMoreItems() && !isLoading.get()) { - loadMoreItems(); - } - } - - protected void showInfoItemDialog(final StreamInfoItem item) { - try { - new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - super.onCreateOptionsMenu(menu, inflater); - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(true); - supportActionBar.setDisplayHomeAsUpEnabled(!useAsFrontPage); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void startLoading(final boolean forceLoad) { - useInitialItemListLoadScrollListener(); - super.startLoading(forceLoad); - } - - protected abstract void loadMoreItems(); - - protected abstract boolean hasMoreItems(); - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animateHideRecyclerViewAllowingScrolling(itemsList); - } - - @Override - public void hideLoading() { - super.hideLoading(); - animate(itemsList, true, 300); - } - - @Override - public void showEmptyState() { - super.showEmptyState(); - showListFooter(false); - animateHideRecyclerViewAllowingScrolling(itemsList); - } - - @Override - public void showListFooter(final boolean show) { - itemsList.post(() -> { - if (infoListAdapter != null && itemsList != null) { - infoListAdapter.showFooter(show); - } - }); - } - - @Override - public void handleNextItems(final N result) { - isLoading.set(false); - } - - @Override - public void handleError() { - super.handleError(); - showListFooter(false); - animateHideRecyclerViewAllowingScrolling(itemsList); - } - - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (getString(R.string.list_view_mode_key).equals(key)) { - updateFlags |= LIST_MODE_UPDATE_FLAG; - } - } - - /** - * Returns preferred item view mode. - * @return ItemViewMode - */ - protected ItemViewMode getItemViewMode() { - return ThemeHelper.getItemViewMode(requireContext()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java deleted file mode 100644 index 848dfe6f5..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ /dev/null @@ -1,291 +0,0 @@ -package org.schabi.newpipe.fragments.list; - -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.views.NewPipeRecyclerView; - -import java.util.ArrayList; -import java.util.List; -import java.util.Queue; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public abstract class BaseListInfoFragment> - extends BaseListFragment> { - @State - protected int serviceId = Constants.NO_SERVICE_ID; - @State - protected String name; - @State - protected String url; - - private final UserAction errorUserAction; - protected L currentInfo; - @Nullable - protected Page currentNextPage; - protected Disposable currentWorker; - - protected BaseListInfoFragment(final UserAction errorUserAction) { - this.errorUserAction = errorUserAction; - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - setTitle(name); - showListFooter(hasMoreItems()); - } - - @Override - public void onPause() { - super.onPause(); - if (currentWorker != null) { - currentWorker.dispose(); - } - } - - @Override - public void onResume() { - super.onResume(); - // Check if it was loading when the fragment was stopped/paused, - if (wasLoading.getAndSet(false)) { - if (hasMoreItems() && !infoListAdapter.getItemsList().isEmpty()) { - loadMoreItems(); - } else { - doInitialLoadLogic(); - } - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (currentWorker != null) { - currentWorker.dispose(); - currentWorker = null; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(final Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(currentInfo); - objectsToSave.add(currentNextPage); - } - - @Override - @SuppressWarnings("unchecked") - public void readFrom(@NonNull final Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - currentInfo = (L) savedObjects.poll(); - currentNextPage = (Page) savedObjects.poll(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void doInitialLoadLogic() { - if (DEBUG) { - Log.d(TAG, "doInitialLoadLogic() called"); - } - if (currentInfo == null) { - startLoading(false); - } else { - handleResult(currentInfo); - } - } - - /** - * Implement the logic to load the info from the network.
- * You can use the default implementations from {@link org.schabi.newpipe.util.ExtractorHelper}. - * - * @param forceLoad allow or disallow the result to come from the cache - * @return Rx {@link Single} containing the {@link ListInfo} - */ - protected abstract Single loadResult(boolean forceLoad); - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - showListFooter(false); - infoListAdapter.clearStreamItemList(); - - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - currentWorker = loadResult(forceLoad) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe((@NonNull final L result) -> { - isLoading.set(false); - currentInfo = result; - currentNextPage = result.getNextPage(); - handleResult(result); - }, throwable -> - showError(new ErrorInfo(throwable, errorUserAction, - "Start loading: " + url, serviceId, url))); - } - - /** - * Implement the logic to load more items. - *

You can use the default implementations - * from {@link org.schabi.newpipe.util.ExtractorHelper}.

- * - * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} - */ - protected abstract Single> loadMoreItemsLogic(); - - @Override - protected void loadMoreItems() { - isLoading.set(true); - - if (currentWorker != null) { - currentWorker.dispose(); - } - - forbidDownwardFocusScroll(); - - currentWorker = loadMoreItemsLogic() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doFinally(this::allowDownwardFocusScroll) - .subscribe(infoItemsPage -> { - isLoading.set(false); - handleNextItems(infoItemsPage); - }, (@NonNull Throwable throwable) -> - dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable, - errorUserAction, "Loading more items: " + url, serviceId, url))); - } - - private void forbidDownwardFocusScroll() { - if (itemsList instanceof NewPipeRecyclerView) { - ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(false); - } - } - - private void allowDownwardFocusScroll() { - if (itemsList instanceof NewPipeRecyclerView) { - ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(true); - } - } - - @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { - super.handleNextItems(result); - - currentNextPage = result.getNextPage(); - infoListAdapter.addInfoItemList(result.getItems()); - - showListFooter(hasMoreItems()); - - if (!result.getErrors().isEmpty()) { - dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction, - "Get next items of: " + url, serviceId, url)); - } - } - - @Override - protected boolean hasMoreItems() { - return Page.isValid(currentNextPage); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void handleResult(@NonNull final L result) { - super.handleResult(result); - - name = result.getName(); - setTitle(name); - - if (infoListAdapter.getItemsList().isEmpty()) { - if (!result.getRelatedItems().isEmpty()) { - infoListAdapter.addInfoItemList(result.getRelatedItems()); - showListFooter(hasMoreItems()); - } else if (hasMoreItems()) { - loadMoreItems(); - } else { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - } - } - - if (!result.getErrors().isEmpty()) { - final List errors = new ArrayList<>(result.getErrors()); - // handling ContentNotSupportedException not to show the error but an appropriate string - // so that crashes won't be sent uselessly and the user will understand what happened - errors.removeIf(ContentNotSupportedException.class::isInstance); - - if (!errors.isEmpty()) { - dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), - errorUserAction, "Start loading: " + url, serviceId, url)); - } - } - } - - @Override - public void showEmptyState() { - // show "no streams" for SoundCloud; otherwise "no videos" - // showing "no live streams" is handled in KioskFragment - if (emptyStateView != null) { - if (currentInfo.getService() == SoundCloud) { - setEmptyStateMessage(R.string.no_streams); - } else { - setEmptyStateMessage(R.string.no_videos); - } - } - super.showEmptyState(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - protected void setInitialData(final int sid, final String u, final String title) { - this.serviceId = sid; - this.url = u; - this.name = !TextUtils.isEmpty(title) ? title : ""; - } - - private void dynamicallyShowErrorPanelOrSnackbar(final ErrorInfo errorInfo) { - if (infoListAdapter.getItemCount() == 0) { - // show error panel only if no items already visible - showError(errorInfo); - } else { - isLoading.set(false); - showSnackBarError(errorInfo); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java b/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java deleted file mode 100644 index 161d5d524..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.fragments.list; - -import org.schabi.newpipe.fragments.ViewContract; - -public interface ListViewContract extends ViewContract { - void showListFooter(boolean show); - - void handleNextItems(N result); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java deleted file mode 100644 index e3a398139..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.schabi.newpipe.fragments.list.channel; - -import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; - -import java.util.List; - -public class ChannelAboutFragment extends BaseDescriptionFragment { - @State - protected ChannelInfo channelInfo; - - ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) { - this.channelInfo = channelInfo; - } - - public ChannelAboutFragment() { - // keep empty constructor for State when resuming fragment from memory - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0); - } - - @Nullable - @Override - protected Description getDescription() { - return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT); - } - - @NonNull - @Override - protected StreamingService getService() { - return channelInfo.getService(); - } - - @Override - protected int getServiceId() { - return channelInfo.getServiceId(); - } - - @Nullable - @Override - protected String getStreamUrl() { - return null; - } - - @NonNull - @Override - public List getTags() { - return channelInfo.getTags(); - } - - @Override - protected void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { - // There is no upload date available for channels, so hide the relevant UI element - binding.detailUploadDateView.setVisibility(View.GONE); - - if (channelInfo == null) { - return; - } - - if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) { - addMetadataItem(inflater, layout, false, R.string.metadata_subscribers, - Localization.localizeNumber(channelInfo.getSubscriberCount())); - } - - addImagesMetadataItem(inflater, layout, R.string.metadata_avatars, - channelInfo.getAvatars()); - addImagesMetadataItem(inflater, layout, R.string.metadata_banners, - channelInfo.getBanners()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java deleted file mode 100644 index 97481f25b..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ /dev/null @@ -1,654 +0,0 @@ -package org.schabi.newpipe.fragments.list.channel; - -import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; - -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.ColorUtils; -import androidx.core.view.MenuProvider; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.google.android.material.snackbar.Snackbar; -import com.google.android.material.tabs.TabLayout; -import com.jakewharton.rxbinding4.view.RxView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.NotificationMode; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.databinding.FragmentChannelBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.fragments.detail.TabAdapter; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.feed.notifications.NotificationHelper; -import org.schabi.newpipe.local.subscription.SubscriptionManager; -import org.schabi.newpipe.util.ChannelTabHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.util.image.ImageStrategy; - -import java.util.List; -import java.util.Queue; -import java.util.concurrent.TimeUnit; - -import coil3.util.CoilUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.functions.Action; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class ChannelFragment extends BaseStateFragment - implements StateSaver.WriteRead { - - private static final int BUTTON_DEBOUNCE_INTERVAL = 100; - - @State - protected int serviceId = Constants.NO_SERVICE_ID; - @State - protected String name; - @State - protected String url; - - private ChannelInfo currentInfo; - private Disposable currentWorker; - private final CompositeDisposable disposables = new CompositeDisposable(); - private Disposable subscribeButtonMonitor; - private SubscriptionManager subscriptionManager; - private int lastTab; - private boolean channelContentNotSupported = false; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private FragmentChannelBinding binding; - private TabAdapter tabAdapter; - - private MenuItem menuRssButton; - private MenuItem menuNotifyButton; - private SubscriptionEntity channelSubscription; - private MenuProvider menuProvider; - - public static ChannelFragment getInstance(final int serviceId, final String url, - final String name) { - final ChannelFragment instance = new ChannelFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - private void setInitialData(final int sid, final String u, final String title) { - this.serviceId = sid; - this.url = u; - this.name = !TextUtils.isEmpty(title) ? title : ""; - } - - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - subscriptionManager = new SubscriptionManager(activity); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentChannelBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - menuProvider = new MenuProvider() { - @Override - public void onCreateMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - inflater.inflate(R.menu.menu_channel, menu); - - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - - } - - @Override - public void onPrepareMenu(@NonNull final Menu menu) { - menuRssButton = menu.findItem(R.id.menu_item_rss); - menuNotifyButton = menu.findItem(R.id.menu_item_notify); - updateRssButton(); - updateNotifyButton(channelSubscription); - } - - @Override - public boolean onMenuItemSelected(@NonNull final MenuItem item) { - final int itemId = item.getItemId(); - if (itemId == R.id.menu_item_notify) { - final boolean value = !item.isChecked(); - item.setEnabled(false); - setNotify(value); - } else if (itemId == R.id.action_settings) { - NavigationHelper.openSettings(requireContext()); - } else if (itemId == R.id.menu_item_rss) { - if (currentInfo != null) { - ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); - } - } else if (itemId == R.id.menu_item_openInBrowser) { - if (currentInfo != null) { - ShareUtils.openUrlInBrowser(requireContext(), - currentInfo.getOriginalUrl()); - } - } else if (itemId == R.id.menu_item_share) { - if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, - currentInfo.getOriginalUrl(), currentInfo.getAvatars()); - } - } else { - return false; - } - return true; - } - }; - activity.addMenuProvider(menuProvider); - } - - @Override // called from onViewCreated in BaseFragment.onViewCreated - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - tabAdapter = new TabAdapter(getChildFragmentManager()); - binding.viewPager.setAdapter(tabAdapter); - binding.tabLayout.setupWithViewPager(binding.viewPager); - - setTitle(name); - binding.channelTitleView.setText(name); - if (!ImageStrategy.shouldLoadImages()) { - // do not waste space for the banner if it is not going to be loaded - binding.channelBannerImage.setImageDrawable(null); - } - } - - @Override - protected void initListeners() { - super.initListeners(); - - final View.OnClickListener openSubChannel = v -> { - if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - currentInfo.getParentChannelUrl(), - currentInfo.getParentChannelName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - } else if (DEBUG) { - Log.i(TAG, "Can't open parent channel because we got no channel URL"); - } - }; - binding.subChannelAvatarView.setOnClickListener(openSubChannel); - binding.subChannelTitleView.setOnClickListener(openSubChannel); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (menuProvider != null) { - activity.removeMenuProvider(menuProvider); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (currentWorker != null) { - currentWorker.dispose(); - } - disposables.clear(); - binding = null; - menuProvider = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Channel Subscription - //////////////////////////////////////////////////////////////////////////*/ - - private void monitorSubscription(final ChannelInfo info) { - final Consumer onError = (final Throwable throwable) -> { - animate(binding.channelSubscribeButton, false, 100); - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, - "Get subscription status", currentInfo)); - }; - - final Observable> observable = subscriptionManager - .subscriptionTable() - .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) - .toObservable(); - - disposables.add(observable - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscribeUpdateMonitor(info), onError)); - - disposables.add(observable - .map(List::isEmpty) - .distinctUntilChanged() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError)); - - disposables.add(observable - .map(List::isEmpty) - .distinctUntilChanged() - .skip(1) // channel has just been opened - .filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(isEmpty -> { - if (!isEmpty) { - showNotifySnackbar(); - } - }, onError)); - } - - private Function mapOnSubscribe(final SubscriptionEntity subscription) { - return (@NonNull final Object o) -> { - subscriptionManager.insertSubscription(subscription); - return o; - }; - } - - private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { - return (@NonNull final Object o) -> { - subscriptionManager.deleteSubscription(subscription); - return o; - }; - } - - private void updateSubscription(final ChannelInfo info) { - if (DEBUG) { - Log.d(TAG, "updateSubscription() called with: info = [" + info + "]"); - } - final Action onComplete = () -> { - if (DEBUG) { - Log.d(TAG, "Updated subscription: " + info.getUrl()); - } - }; - - final Consumer onError = (@NonNull Throwable throwable) -> - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_UPDATE, - "Updating subscription for " + info.getUrl(), info)); - - disposables.add(subscriptionManager.updateChannelInfo(info) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(onComplete, onError)); - } - - private Disposable monitorSubscribeButton(final Function action) { - final Consumer onNext = (@NonNull final Object o) -> { - if (DEBUG) { - Log.d(TAG, "Changed subscription status to this channel!"); - } - }; - - final Consumer onError = (@NonNull Throwable throwable) -> - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_CHANGE, - "Changing subscription for " + currentInfo.getUrl(), currentInfo)); - - /* Emit clicks from main thread unto io thread */ - return RxView.clicks(binding.channelSubscribeButton) - .subscribeOn(AndroidSchedulers.mainThread()) - .observeOn(Schedulers.io()) - .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks - .map(action) - .subscribe(onNext, onError); - } - - private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { - return (final List subscriptionEntities) -> { - if (DEBUG) { - Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " - + "subscriptionEntities = [" + subscriptionEntities + "]"); - } - if (subscribeButtonMonitor != null) { - subscribeButtonMonitor.dispose(); - } - - if (subscriptionEntities.isEmpty()) { - if (DEBUG) { - Log.d(TAG, "No subscription to this channel!"); - } - final SubscriptionEntity channel = new SubscriptionEntity(); - channel.setServiceId(info.getServiceId()); - channel.setUrl(info.getUrl()); - channel.setName(info.getName()); - channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars())); - channel.setDescription(info.getDescription()); - channel.setSubscriberCount(info.getSubscriberCount()); - channelSubscription = null; - updateNotifyButton(null); - subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); - } else { - if (DEBUG) { - Log.d(TAG, "Found subscription to this channel!"); - } - channelSubscription = subscriptionEntities.get(0); - updateNotifyButton(channelSubscription); - subscribeButtonMonitor = - monitorSubscribeButton(mapOnUnsubscribe(channelSubscription)); - } - }; - } - - private void updateSubscribeButton(final boolean isSubscribed) { - if (DEBUG) { - Log.d(TAG, "updateSubscribeButton() called with: " - + "isSubscribed = [" + isSubscribed + "]"); - } - - final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility() - == View.VISIBLE; - final int backgroundDuration = isButtonVisible ? 300 : 0; - final int textDuration = isButtonVisible ? 200 : 0; - - final int subscribedBackground = ContextCompat - .getColor(activity, R.color.subscribed_background_color); - final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); - final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper - .resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f); - final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color); - - if (isSubscribed) { - binding.channelSubscribeButton.setText(R.string.subscribed_button_title); - animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, - subscribeBackground, subscribedBackground); - animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText, - subscribedText); - } else { - binding.channelSubscribeButton.setText(R.string.subscribe_button_title); - animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, - subscribedBackground, subscribeBackground); - animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText, - subscribeText); - } - - animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA); - } - - private void updateRssButton() { - if (menuRssButton == null || currentInfo == null) { - return; - } - menuRssButton.setVisible(!TextUtils.isEmpty(currentInfo.getFeedUrl())); - } - - private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { - if (menuNotifyButton == null) { - return; - } - if (subscription != null) { - menuNotifyButton.setEnabled( - NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()) - ); - menuNotifyButton.setChecked( - subscription.getNotificationMode() == NotificationMode.ENABLED - ); - } - - menuNotifyButton.setVisible(subscription != null); - } - - private void setNotify(final boolean isEnabled) { - disposables.add( - subscriptionManager - .updateNotificationMode( - currentInfo.getServiceId(), - currentInfo.getUrl(), - isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - ); - } - - /** - * Show a snackbar with the option to enable notifications on new streams for this channel. - */ - private void showNotifySnackbar() { - Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) - .setAction(R.string.get_notified, v -> setNotify(true)) - .setActionTextColor(Color.YELLOW) - .show(); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - private void updateTabs() { - tabAdapter.clearAllItems(); - - if (currentInfo != null && !channelContentNotSupported) { - final Context context = requireContext(); - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(context); - - for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { - final String tab = linkHandler.getContentFilters().get(0); - if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { - final ChannelTabFragment channelTabFragment = - ChannelTabFragment.getInstance(serviceId, linkHandler, name); - channelTabFragment.useAsFrontPage(useAsFrontPage); - tabAdapter.addFragment(channelTabFragment, - context.getString(ChannelTabHelper.getTranslationKey(tab))); - } - } - - if (ChannelTabHelper.showChannelTab( - context, preferences, R.string.show_channel_tabs_about)) { - tabAdapter.addFragment( - new ChannelAboutFragment(currentInfo), - context.getString(R.string.channel_tab_about)); - } - } - - tabAdapter.notifyDataSetUpdate(); - - for (int i = 0; i < tabAdapter.getCount(); i++) { - binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i)); - } - - // Restore previously selected tab - final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab); - if (ltab != null) { - binding.tabLayout.selectTab(ltab); - } - } - - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public String generateSuffix() { - return null; - } - - @Override - public void writeTo(final Queue objectsToSave) { - objectsToSave.add(currentInfo); - objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition()); - } - - @Override - public void readFrom(@NonNull final Queue savedObjects) { - currentInfo = (ChannelInfo) savedObjects.poll(); - lastTab = (Integer) savedObjects.poll(); - } - - @Override - public void onSaveInstanceState(final @NonNull Bundle outState) { - super.onSaveInstanceState(outState); - if (binding != null) { - outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); - } - } - - @Override - protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - lastTab = savedInstanceState.getInt("LastTab", 0); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void doInitialLoadLogic() { - if (currentInfo == null) { - startLoading(false); - } else { - handleResult(currentInfo); - } - } - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - currentInfo = null; - updateTabs(); - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad); - } - - private void runWorker(final boolean forceLoad) { - currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - isLoading.set(false); - handleResult(result); - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, - url == null ? "No URL" : url, serviceId, url))); - } - - @Override - public void showLoading() { - super.showLoading(); - CoilUtils.dispose(binding.channelAvatarView); - CoilUtils.dispose(binding.channelBannerImage); - CoilUtils.dispose(binding.subChannelAvatarView); - animate(binding.channelSubscribeButton, false, 100); - } - - @Override - public void handleResult(@NonNull final ChannelInfo result) { - super.handleResult(result); - currentInfo = result; - setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()); - - if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { - CoilHelper.INSTANCE.loadBanner(binding.channelBannerImage, result.getBanners()); - } else { - // do not waste space for the banner, if the user disabled images or there is not one - binding.channelBannerImage.setImageDrawable(null); - } - - CoilHelper.INSTANCE.loadAvatar(binding.channelAvatarView, result.getAvatars()); - CoilHelper.INSTANCE.loadAvatar(binding.subChannelAvatarView, - result.getParentChannelAvatars()); - - binding.channelTitleView.setText(result.getName()); - binding.channelSubscriberView.setVisibility(View.VISIBLE); - if (result.getSubscriberCount() >= 0) { - binding.channelSubscriberView.setText(Localization - .shortSubscriberCount(activity, result.getSubscriberCount())); - } else { - binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); - } - - if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { - binding.subChannelTitleView.setText(String.format( - getString(R.string.channel_created_by), - currentInfo.getParentChannelName()) - ); - binding.subChannelTitleView.setVisibility(View.VISIBLE); - binding.subChannelAvatarView.setVisibility(View.VISIBLE); - } - - updateRssButton(); - - channelContentNotSupported = false; - for (final Throwable throwable : result.getErrors()) { - if (throwable instanceof ContentNotSupportedException) { - channelContentNotSupported = true; - showContentNotSupportedIfNeeded(); - break; - } - } - - disposables.clear(); - if (subscribeButtonMonitor != null) { - subscribeButtonMonitor.dispose(); - } - - updateTabs(); - updateSubscription(result); - monitorSubscription(result); - } - - private void showContentNotSupportedIfNeeded() { - // channelBinding might not be initialized when handleResult() is called - // (e.g. after rotating the screen, #6696) - if (!channelContentNotSupported || binding == null) { - return; - } - - binding.errorContentNotSupported.setVisibility(View.VISIBLE); - binding.channelKaomoji.setText("(︶︹︺)"); - binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java deleted file mode 100644 index 5d398821a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.schabi.newpipe.fragments.list.channel; - -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; -import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.util.ChannelTabHelper; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.PlayButtonHelper; - -import java.util.List; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.core.Single; - -public class ChannelTabFragment extends BaseListInfoFragment - implements PlaylistControlViewHolder { - - // states must be protected and not private for State being able to access them - @State - protected ListLinkHandler tabHandler; - @State - protected String channelName; - - private PlaylistControlBinding playlistControlBinding; - - @NonNull - public static ChannelTabFragment getInstance(final int serviceId, - final ListLinkHandler tabHandler, - final String channelName) { - final ChannelTabFragment instance = new ChannelTabFragment(); - instance.serviceId = serviceId; - instance.tabHandler = tabHandler; - instance.channelName = channelName; - return instance; - } - - public ChannelTabFragment() { - super(UserAction.REQUESTED_CHANNEL); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(false); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_channel_tab, container, false); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - playlistControlBinding = null; - } - - @Override - protected Supplier getListHeaderSupplier() { - if (ChannelTabHelper.isStreamsTab(tabHandler)) { - playlistControlBinding = PlaylistControlBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - return playlistControlBinding::getRoot; - } - return null; - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad); - } - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage); - } - - @Override - public void setTitle(final String title) { - // The channel name is displayed as title in the toolbar. - // The title is always a description of the content of the tab fragment. - // It should be unique for each channel because multiple channel tabs - // can be added to the main page. Therefore, the channel name is used. - // Using the title variable would cause the title to be the same for all channel tabs. - super.setTitle(channelName); - } - - @Override - public void handleResult(@NonNull final ChannelTabInfo result) { - super.handleResult(result); - - // FIXME this is a really hacky workaround, to avoid storing useless data in the fragment - // state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that - // uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if - // you combine just a couple of channel tab fragments you easily go over the 1MB - // save&restore transaction limit, and get `TransactionTooLargeException`s. A proper - // solution would require rethinking about `ReadyChannelTabListLinkHandler`s. - if (tabHandler instanceof ReadyChannelTabListLinkHandler) { - try { - // once `handleResult` is called, the parsed data was already saved to cache, so - // we can discard any raw data in ReadyChannelTabListLinkHandler and create a - // link handler with identical properties, but without any raw data - final ListLinkHandlerFactory channelTabLHFactory = result.getService() - .getChannelTabLHFactory(); - if (channelTabLHFactory != null) { - // some services do not not have a ChannelTabLHFactory - tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(), - tabHandler.getContentFilters(), tabHandler.getSortFilter()); - } - } catch (final ParsingException e) { - // silently ignore the error, as the app can continue to function normally - Log.w(TAG, "Could not recreate channel tab handler", e); - } - } - - if (playlistControlBinding != null) { - // PlaylistControls should be visible only if there is some item in - // infoListAdapter other than header - if (infoListAdapter.getItemCount() > 1) { - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - } else { - playlistControlBinding.getRoot().setVisibility(View.GONE); - } - - PlayButtonHelper.initPlaylistControlClickListener( - activity, playlistControlBinding, this); - } - } - - @Override - public PlayQueue getPlayQueue() { - final List streamItems = infoListAdapter.getItemsList().stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()); - - return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, - currentInfo.getNextPage(), streamItems, 0); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java deleted file mode 100644 index ed7dd5a8c..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.text.HtmlCompat; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.text.TextLinkifier; -import org.schabi.newpipe.util.text.LongPressLinkMovementMethod; - -import java.util.Queue; -import java.util.function.Supplier; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public final class CommentRepliesFragment - extends BaseListInfoFragment { - - public static final String TAG = CommentRepliesFragment.class.getSimpleName(); - - @State - CommentsInfoItem commentsInfoItem; // the comment to show replies of - private final CompositeDisposable disposables = new CompositeDisposable(); - - - /*////////////////////////////////////////////////////////////////////////// - // Constructors and lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - // only called by the Android framework, after which readFrom is called and restores all data - public CommentRepliesFragment() { - super(UserAction.REQUESTED_COMMENT_REPLIES); - } - - public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) { - this(); - this.commentsInfoItem = commentsInfoItem; - // setting "" as title since the title will be properly set right after - setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), ""); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_comments, container, false); - } - - @Override - public void onDestroyView() { - disposables.clear(); - super.onDestroyView(); - } - - @Override - protected Supplier getListHeaderSupplier() { - return () -> { - final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - final CommentsInfoItem item = commentsInfoItem; - - // load the author avatar - CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars()); - binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages() - ? View.VISIBLE : View.GONE); - - // setup author name and comment date - binding.authorName.setText(item.getUploaderName()); - binding.uploadDate.setText(Localization.relativeTimeOrTextual( - getContext(), item.getUploadDate(), item.getTextualUploadDate())); - binding.authorTouchArea.setOnClickListener( - v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item)); - - // setup like count, hearted and pinned - binding.thumbsUpCount.setText( - Localization.likeCount(requireContext(), item.getLikeCount())); - // for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout - // not to use a different margin only when both the next two views are gone - ((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams()) - .setMarginEnd(DeviceUtils.dpToPx( - (item.isHeartedByUploader() || item.isPinned() ? 8 : 16), - requireContext())); - binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - - // setup comment content - TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), - HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), - item.getUrl(), disposables, null); - binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance()); - return binding.getRoot(); - }; - } - - - /*////////////////////////////////////////////////////////////////////////// - // State saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(final Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(commentsInfoItem); - } - - @Override - public void readFrom(@NonNull final Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - commentsInfoItem = (CommentsInfoItem) savedObjects.poll(); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Data loading - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem, - // the reply count string will be shown as the activity title - Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount()))); - } - - @Override - protected Single> loadMoreItemsLogic() { - // commentsInfoItem.getUrl() should contain the url of the original - // ListInfo, which should be the stream url - return ExtractorHelper.getMoreCommentItems( - serviceId, commentsInfoItem.getUrl(), currentNextPage); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected ItemViewMode getItemViewMode() { - return ItemViewMode.LIST; - } - - /** - * @return the comment to which the replies are shown - */ - public CommentsInfoItem getCommentsInfoItem() { - return commentsInfoItem; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java deleted file mode 100644 index cc160c395..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; - -import java.util.Collections; - -public final class CommentRepliesInfo extends ListInfo { - /** - * This class is used to wrap the comment replies page into a ListInfo object. - * - * @param comment the comment from which to get replies - * @param name will be shown as the fragment title - */ - public CommentRepliesInfo(final CommentsInfoItem comment, final String name) { - super(comment.getServiceId(), - new ListLinkHandler("", "", "", Collections.emptyList(), null), name); - setNextPage(comment.getReplies()); - setRelatedItems(Collections.emptyList()); // since it must be non-null - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java deleted file mode 100644 index e25e02794..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ /dev/null @@ -1,123 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.comments.CommentsInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.util.ExtractorHelper; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class CommentsFragment extends BaseListInfoFragment { - private final CompositeDisposable disposables = new CompositeDisposable(); - - private TextView emptyStateDesc; - - public static CommentsFragment getInstance(final int serviceId, final String url, - final String name) { - final CommentsFragment instance = new CommentsFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - public CommentsFragment() { - super(UserAction.REQUESTED_COMMENTS); - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - emptyStateDesc = rootView.findViewById(R.id.empty_state_desc); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_comments, container, false); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void handleResult(@NonNull final CommentsInfo result) { - super.handleResult(result); - - emptyStateDesc.setText( - result.isCommentsDisabled() - ? R.string.comments_are_disabled - : R.string.no_comments); - - ViewUtils.slideUp(requireView(), 120, 150, 0.06f); - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { } - - @Override - protected ItemViewMode getItemViewMode() { - return ItemViewMode.LIST; - } - - public boolean scrollToComment(final CommentsInfoItem comment) { - final int position = infoListAdapter.getItemsList().indexOf(comment); - if (position < 0) { - return false; - } - - itemsList.scrollToPosition(position); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java deleted file mode 100644 index d0b9e3a3d..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.schabi.newpipe.fragments.list.kiosk; - -import android.os.Bundle; - -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.kiosk.KioskList; -import org.schabi.newpipe.util.KioskTranslator; -import org.schabi.newpipe.util.ServiceHelper; - -public class DefaultKioskFragment extends KioskFragment { - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (serviceId < 0) { - updateSelectedDefaultKiosk(); - } - } - - @Override - public void onResume() { - super.onResume(); - - if (serviceId != ServiceHelper.getSelectedServiceId(requireContext())) { - if (currentWorker != null) { - currentWorker.dispose(); - } - updateSelectedDefaultKiosk(); - reloadContent(); - } - } - - private void updateSelectedDefaultKiosk() { - try { - serviceId = ServiceHelper.getSelectedServiceId(requireContext()); - - final KioskList kioskList = NewPipe.getService(serviceId).getKioskList(); - kioskId = kioskList.getDefaultKioskId(); - url = kioskList.getListLinkHandlerFactoryByType(kioskId).fromId(kioskId).getUrl(); - - kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, requireContext()); - name = kioskTranslatedName; - - currentInfo = null; - currentNextPage = null; - } catch (final ExtractionException e) { - showError(new ErrorInfo(e, UserAction.REQUESTED_KIOSK, - "Loading default kiosk for selected service")); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java deleted file mode 100644 index d3427f8db..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.fragments.list.kiosk; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.kiosk.KioskInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; -import org.schabi.newpipe.extractor.localization.ContentCountry; -import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.KioskTranslator; -import org.schabi.newpipe.util.Localization; - -import io.reactivex.rxjava3.core.Single; - -public class KioskFragment extends BaseListInfoFragment { - @State - String kioskId = ""; - String kioskTranslatedName; - @State - ContentCountry contentCountry; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - public static KioskFragment getInstance(final int serviceId) throws ExtractionException { - return getInstance(serviceId, NewPipe.getService(serviceId) - .getKioskList().getDefaultKioskId()); - } - - public static KioskFragment getInstance(final int serviceId, final String kioskId) - throws ExtractionException { - final KioskFragment instance = new KioskFragment(); - final StreamingService service = NewPipe.getService(serviceId); - final ListLinkHandlerFactory kioskLinkHandlerFactory = service.getKioskList() - .getListLinkHandlerFactoryByType(kioskId); - instance.setInitialData(serviceId, - kioskLinkHandlerFactory.fromId(kioskId).getUrl(), kioskId); - instance.kioskId = kioskId; - return instance; - } - - public KioskFragment() { - super(UserAction.REQUESTED_KIOSK); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, activity); - name = kioskTranslatedName; - contentCountry = Localization.getPreferredContentCountry(requireContext()); - } - - @Override - public void onResume() { - super.onResume(); - if (!Localization.getPreferredContentCountry(requireContext()).equals(contentCountry)) { - reloadContent(); - } - if (useAsFrontPage && activity != null) { - try { - setTitle(kioskTranslatedName); - } catch (final Exception e) { - showSnackBarError(new ErrorInfo(e, UserAction.UI_ERROR, "Setting kiosk title")); - } - } - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_kiosk, container, false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null && useAsFrontPage) { - supportActionBar.setDisplayHomeAsUpEnabled(false); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public Single loadResult(final boolean forceReload) { - contentCountry = Localization.getPreferredContentCountry(requireContext()); - return ExtractorHelper.getKioskInfo(serviceId, url, forceReload); - } - - @Override - public Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void handleResult(@NonNull final KioskInfo result) { - super.handleResult(result); - - name = kioskTranslatedName; - setTitle(kioskTranslatedName); - } - - @Override - public void showEmptyState() { - // show "no live streams" for live stream kiosk - super.showEmptyState(); - if (MediaCCCLiveStreamKiosk.KIOSK_ID.equals(currentInfo.getId()) - && ServiceList.MediaCCC.getServiceId() == currentInfo.getServiceId()) { - setEmptyStateMessage(R.string.no_live_streams); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java deleted file mode 100644 index e4705bb71..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.schabi.newpipe.fragments.list.playlist; - -import org.schabi.newpipe.player.playqueue.PlayQueue; - -/** - * Interface for {@code R.layout.playlist_control} view holders - * to give access to the play queue. - */ -public interface PlaylistControlViewHolder { - PlayQueue getPlayQueue(); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java deleted file mode 100644 index 8f0c3ac98..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ /dev/null @@ -1,509 +0,0 @@ -package org.schabi.newpipe.fragments.list.playlist; - -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; - -import android.content.Context; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; - -import com.google.android.material.shape.CornerFamily; -import com.google.android.material.shape.ShapeAppearanceModel; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.databinding.PlaylistHeaderBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.util.text.TextEllipsizer; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import coil3.util.CoilUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class PlaylistFragment extends BaseListInfoFragment - implements PlaylistControlViewHolder { - - private CompositeDisposable disposables; - private Subscription bookmarkReactor; - private AtomicBoolean isBookmarkButtonReady; - - private RemotePlaylistManager remotePlaylistManager; - private PlaylistRemoteEntity playlistEntity; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private PlaylistHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - private MenuItem playlistBookmarkButton; - - private long streamCount; - private long playlistOverallDurationSeconds; - - public static PlaylistFragment getInstance(final int serviceId, final String url, - final String name) { - final PlaylistFragment instance = new PlaylistFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - public PlaylistFragment() { - super(UserAction.REQUESTED_PLAYLIST); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - disposables = new CompositeDisposable(); - isBookmarkButtonReady = new AtomicBoolean(false); - remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase - .getInstance(requireContext())); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = PlaylistHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - // Is mini variant still relevant? - // Only the remote playlist screen uses it now - infoListAdapter.setUseMiniVariant(true); - } - - private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { - return getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(infoItem), 0)); - } - - @Override - protected void showInfoItemDialog(final StreamInfoItem item) { - final Context context = getContext(); - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, item); - - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(infoItem), true)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_playlist, menu); - - playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); - updateBookmarkButtons(); - } - - @Override - public void onDestroyView() { - headerBinding = null; - playlistControlBinding = null; - - super.onDestroyView(); - if (isBookmarkButtonReady != null) { - isBookmarkButtonReady.set(false); - } - - if (disposables != null) { - disposables.clear(); - } - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - - bookmarkReactor = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (disposables != null) { - disposables.dispose(); - } - - disposables = null; - remotePlaylistManager = null; - playlistEntity = null; - isBookmarkButtonReady = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final int itemId = item.getItemId(); - if (itemId == R.id.action_settings) { - NavigationHelper.openSettings(requireContext()); - } else if (itemId == R.id.menu_item_openInBrowser) { - ShareUtils.openUrlInBrowser(requireContext(), url); - } else if (itemId == R.id.menu_item_share) { - ShareUtils.shareText(requireContext(), name, url, - currentInfo == null ? List.of() : currentInfo.getThumbnails()); - } else if (itemId == R.id.menu_item_bookmark) { - onBookmarkClicked(); - } else if (itemId == R.id.menu_item_append_playlist) { - if (currentInfo != null) { - disposables.add(PlaylistDialog.createCorrespondingDialog( - getContext(), - getPlayQueue() - .getStreams() - .stream() - .map(StreamEntity::new) - .collect(Collectors.toList()), - dialog -> dialog.show(getFM(), TAG) - )); - } - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animate(headerBinding.getRoot(), false, 200); - animateHideRecyclerViewAllowingScrolling(itemsList); - - CoilUtils.dispose(headerBinding.uploaderAvatarView); - animate(headerBinding.uploaderLayout, false, 200); - } - - @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { - super.handleNextItems(result); - setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage()); - } - - @Override - public void handleResult(@NonNull final PlaylistInfo result) { - super.handleResult(result); - - animate(headerBinding.getRoot(), true, 100); - animate(headerBinding.uploaderLayout, true, 300); - headerBinding.uploaderLayout.setOnClickListener(null); - // If we have an uploader put them into the UI - if (!TextUtils.isEmpty(result.getUploaderName())) { - headerBinding.uploaderName.setText(result.getUploaderName()); - if (!TextUtils.isEmpty(result.getUploaderUrl())) { - headerBinding.uploaderLayout.setOnClickListener(v -> { - try { - NavigationHelper.openChannelFragment(getFM(), result.getServiceId(), - result.getUploaderUrl(), result.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - }); - } - } else { // Otherwise say we have no uploader - headerBinding.uploaderName.setText(R.string.playlist_no_uploader); - } - - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - - if (result.getServiceId() == ServiceList.YouTube.getServiceId() - && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) - || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { - // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown - final ShapeAppearanceModel model = ShapeAppearanceModel.builder() - .setAllCorners(CornerFamily.ROUNDED, 0f) - .build(); // this turns the image back into a square - headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); - headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources - .getColorStateList(requireContext(), R.color.transparent_background_color)); - headerBinding.uploaderAvatarView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), - R.drawable.ic_radio) - ); - } else { - CoilHelper.INSTANCE.loadAvatar(headerBinding.uploaderAvatarView, - result.getUploaderAvatars()); - } - - streamCount = result.getStreamCount(); - setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()); - - final Description description = result.getDescription(); - if (description != null && description != Description.EMPTY_DESCRIPTION - && !isBlank(description.getContent())) { - final TextEllipsizer ellipsizer = new TextEllipsizer( - headerBinding.playlistDescription, 5, getServiceById(result.getServiceId())); - ellipsizer.setStateChangeListener(isEllipsized -> - headerBinding.playlistDescriptionReadMore.setText( - Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less - )); - ellipsizer.setOnContentChanged(canBeEllipsized -> { - headerBinding.playlistDescriptionReadMore.setVisibility( - Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); - if (Boolean.TRUE.equals(canBeEllipsized)) { - ellipsizer.ellipsize(); - } - }); - ellipsizer.setContent(description); - headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); - headerBinding.playlistDescription.setOnClickListener(v -> ellipsizer.toggle()); - } else { - headerBinding.playlistDescription.setVisibility(View.GONE); - headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); - } - - if (!result.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, - result.getUrl(), result)); - } - - remotePlaylistManager.getPlaylist(result) - .flatMap(lists -> getUpdateProcessor(lists, result), (lists, id) -> lists) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getPlaylistBookmarkSubscriber()); - - PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); - } - - public PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - final List infoItems = new ArrayList<>(); - for (final InfoItem i : infoListAdapter.getItemsList()) { - if (i instanceof StreamInfoItem) { - infoItems.add((StreamInfoItem) i); - } - } - return new PlaylistPlayQueue( - currentInfo.getServiceId(), - currentInfo.getUrl(), - currentInfo.getNextPage(), - infoItems, - index - ); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private Flowable getUpdateProcessor( - @NonNull final List playlists, - @NonNull final PlaylistInfo result) { - final Flowable noItemToUpdate = Flowable.just(/*noItemToUpdate=*/-1); - if (playlists.isEmpty()) { - return noItemToUpdate; - } - - final PlaylistRemoteEntity playlistRemoteEntity = playlists.get(0); - if (playlistRemoteEntity.isIdenticalTo(result)) { - return noItemToUpdate; - } - - return remotePlaylistManager.onUpdate(playlists.get(0).getUid(), result).toFlowable(); - } - - private Subscriber> getPlaylistBookmarkSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - bookmarkReactor = s; - bookmarkReactor.request(1); - } - - @Override - public void onNext(final List playlist) { - playlistEntity = playlist.isEmpty() ? null : playlist.get(0); - - updateBookmarkButtons(); - isBookmarkButtonReady.set(true); - - if (bookmarkReactor != null) { - bookmarkReactor.request(1); - } - } - - @Override - public void onError(final Throwable throwable) { - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Get playlist bookmarks")); - } - - @Override - public void onComplete() { } - }; - } - - @Override - public void setTitle(final String title) { - super.setTitle(title); - if (headerBinding != null) { - headerBinding.playlistTitleView.setText(title); - } - } - - private void onBookmarkClicked() { - if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() - || remotePlaylistManager == null) { - return; - } - - final Disposable action; - - if (currentInfo != null && playlistEntity == null) { - action = remotePlaylistManager.onBookmark(currentInfo) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Adding playlist bookmark"))); - } else if (playlistEntity != null) { - action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) - .observeOn(AndroidSchedulers.mainThread()) - .doFinally(() -> playlistEntity = null) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Deleting playlist bookmark"))); - } else { - action = Disposable.empty(); - } - - disposables.add(action); - } - - private void updateBookmarkButtons() { - if (playlistBookmarkButton == null || activity == null) { - return; - } - - final int drawable = playlistEntity == null - ? R.drawable.ic_playlist_add : R.drawable.ic_playlist_add_check; - - final int titleRes = playlistEntity == null - ? R.string.bookmark_playlist : R.string.unbookmark_playlist; - - playlistBookmarkButton.setIcon(drawable); - playlistBookmarkButton.setTitle(titleRes); - } - - private void setStreamCountAndOverallDuration(final List list, - final boolean isDurationComplete) { - if (activity != null && headerBinding != null) { - playlistOverallDurationSeconds += list.stream() - .mapToLong(x -> x.getDuration()) - .sum(); - headerBinding.playlistStreamCount.setText( - Localization.concatenateStrings( - Localization.localizeStreamCount(activity, streamCount), - Localization.getDurationString(playlistOverallDurationSeconds, - isDurationComplete, true)) - ); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java deleted file mode 100644 index 5c0d7d321..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ /dev/null @@ -1,1168 +0,0 @@ -package org.schabi.newpipe.fragments.list.search; - -import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static java.util.Arrays.asList; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.Editable; -import android.text.Html; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.style.CharacterStyle; -import android.util.Log; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.DecelerateInterpolator; -import android.view.inputmethod.EditorInfo; -import android.widget.EditText; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.TooltipCompat; -import androidx.collection.SparseArrayCompat; -import androidx.core.text.HtmlCompat; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentSearchBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.MetaInfo; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.search.SearchExtractor; -import org.schabi.newpipe.extractor.search.SearchInfo; -import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory; -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory; -import org.schabi.newpipe.fragments.BackPressable; -import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.KeyboardUtil; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ServiceHelper; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Queue; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; - -public class SearchFragment extends BaseListFragment> - implements BackPressable { - /*////////////////////////////////////////////////////////////////////////// - // Search - //////////////////////////////////////////////////////////////////////////*/ - - /** - * The suggestions will only be fetched from network if the query meet this threshold (>=). - * (local ones will be fetched regardless of the length) - */ - private static final int THRESHOLD_NETWORK_SUGGESTION = 1; - - /** - * How much time have to pass without emitting a item (i.e. the user stop typing) - * to fetch/show the suggestions, in milliseconds. - */ - private static final int SUGGESTIONS_DEBOUNCE = 120; //ms - private final PublishSubject suggestionPublisher = PublishSubject.create(); - - @State - int filterItemCheckedId = -1; - - @State - protected int serviceId = Constants.NO_SERVICE_ID; - - // these three represents the current search query - @State - String searchString; - - /** - * No content filter should add like contentFilter = all - * be aware of this when implementing an extractor. - */ - @State - String[] contentFilter = new String[0]; - - @State - String sortFilter; - - // these represents the last search - @State - String lastSearchedString; - - @State - String searchSuggestion; - - @State - boolean isCorrectedSearch; - - @State - MetaInfo[] metaInfo; - - @State - boolean wasSearchFocused = false; - - private final SparseArrayCompat menuItemToFilterName = new SparseArrayCompat<>(); - private StreamingService service; - @Nullable - private Page nextPage; - private boolean showLocalSuggestions = true; - private boolean showRemoteSuggestions = true; - - private Disposable searchDisposable; - private Disposable suggestionDisposable; - private final CompositeDisposable disposables = new CompositeDisposable(); - - private SuggestionListAdapter suggestionListAdapter; - private HistoryRecordManager historyRecordManager; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private FragmentSearchBinding searchBinding; - - private View searchToolbarContainer; - private EditText searchEditText; - private View searchClear; - - private boolean suggestionsPanelVisible = false; - - /*////////////////////////////////////////////////////////////////////////*/ - - /** - * TextWatcher to remove rich-text formatting on the search EditText when pasting content - * from the clipboard. - */ - private TextWatcher textWatcher; - - public static SearchFragment getInstance(final int serviceId, final String searchString) { - final SearchFragment searchFragment = new SearchFragment(); - searchFragment.setQuery(serviceId, searchString, new String[0], ""); - - if (!TextUtils.isEmpty(searchString)) { - searchFragment.setSearchOnResume(); - } - - return searchFragment; - } - - /** - * Set wasLoading to true so when the fragment onResume is called, the initial search is done. - */ - private void setSearchOnResume() { - wasLoading.set(true); - } - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions(activity, prefs); - showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions(activity, prefs); - - suggestionListAdapter = new SuggestionListAdapter(); - historyRecordManager = new HistoryRecordManager(context); - } - - @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_search, container, false); - } - - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - searchBinding = FragmentSearchBinding.bind(rootView); - super.onViewCreated(rootView, savedInstanceState); - - updateService(); - // Add the service name to search string hint - // to make it more obvious which platform is being searched. - if (service != null) { - searchEditText.setHint( - getString(R.string.search_with_service_name, - service.getServiceInfo().getName())); - } - showSearchOnStart(); - initSearchListeners(); - } - - private void updateService() { - try { - service = NewPipe.getService(serviceId); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Getting service for id " + serviceId, e); - } - } - - @Override - public void onStart() { - if (DEBUG) { - Log.d(TAG, "onStart() called"); - } - super.onStart(); - - updateService(); - } - - @Override - public void onPause() { - super.onPause(); - - wasSearchFocused = searchEditText.hasFocus(); - - if (searchDisposable != null) { - searchDisposable.dispose(); - } - if (suggestionDisposable != null) { - suggestionDisposable.dispose(); - } - disposables.clear(); - hideKeyboardSearch(); - } - - @Override - public void onResume() { - if (DEBUG) { - Log.d(TAG, "onResume() called"); - } - super.onResume(); - - if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { - initSuggestionObserver(); - } - - if (!TextUtils.isEmpty(searchString)) { - if (wasLoading.getAndSet(false)) { - search(searchString, contentFilter, sortFilter); - return; - } else if (infoListAdapter.getItemsList().isEmpty()) { - if (savedState == null) { - search(searchString, contentFilter, sortFilter); - return; - } else if (!isLoading.get() && !wasSearchFocused && lastPanelError == null) { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - } - } - } - - handleSearchSuggestion(); - - showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, - disposables); - - if (TextUtils.isEmpty(searchString) || wasSearchFocused) { - showKeyboardSearch(); - showSuggestionsPanel(); - } else { - hideKeyboardSearch(); - hideSuggestionsPanel(); - } - wasSearchFocused = false; - } - - @Override - public void onDestroyView() { - if (DEBUG) { - Log.d(TAG, "onDestroyView() called"); - } - unsetSearchListeners(); - - searchBinding = null; - super.onDestroyView(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (searchDisposable != null) { - searchDisposable.dispose(); - } - if (suggestionDisposable != null) { - suggestionDisposable.dispose(); - } - disposables.clear(); - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { - if (resultCode == Activity.RESULT_OK - && !TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); - } else { - Log.e(TAG, "ReCaptcha failed"); - } - } else { - Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - searchBinding.suggestionsList.setAdapter(suggestionListAdapter); - // animations are just strange and useless, since the suggestions keep changing too much - searchBinding.suggestionsList.setItemAnimator(null); - new ItemTouchHelper(new ItemTouchHelper.Callback() { - @Override - public int getMovementFlags(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder viewHolder) { - return getSuggestionMovementFlags(viewHolder); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder viewHolder, - @NonNull final RecyclerView.ViewHolder viewHolder1) { - return false; - } - - @Override - public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int i) { - onSuggestionItemSwiped(viewHolder); - } - }).attachToRecyclerView(searchBinding.suggestionsList); - - searchToolbarContainer = activity.findViewById(R.id.toolbar_search_container); - searchEditText = searchToolbarContainer.findViewById(R.id.toolbar_search_edit_text); - searchClear = searchToolbarContainer.findViewById(R.id.toolbar_search_clear); - } - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(final Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(nextPage); - } - - @Override - public void readFrom(@NonNull final Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - nextPage = (Page) savedObjects.poll(); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle bundle) { - searchString = searchEditText != null - ? getSearchEditString().trim() - : searchString; - super.onSaveInstanceState(bundle); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init's - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void reloadContent() { - if (!TextUtils.isEmpty(searchString) || (searchEditText != null - && !isSearchEditBlank())) { - search(!TextUtils.isEmpty(searchString) - ? searchString - : getSearchEditString(), this.contentFilter, ""); - } else { - if (searchEditText != null) { - searchEditText.setText(""); - showKeyboardSearch(); - } - hideErrorPanel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(false); - supportActionBar.setDisplayHomeAsUpEnabled(true); - } - - int itemId = 0; - boolean isFirstItem = true; - final Context c = getContext(); - - if (service == null) { - Log.w(TAG, "onCreateOptionsMenu() called with null service"); - updateService(); - } - - for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) { - if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { - final MenuItem musicItem = menu.add(2, - itemId++, - 0, - "YouTube Music"); - musicItem.setEnabled(false); - } else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) { - final MenuItem sepiaItem = menu.add(2, - itemId++, - 0, - "Sepia Search"); - sepiaItem.setEnabled(false); - } - menuItemToFilterName.put(itemId, filter); - final MenuItem item = menu.add(1, - itemId++, - 0, - ServiceHelper.getTranslatedFilterString(filter, c)); - if (isFirstItem) { - item.setChecked(true); - isFirstItem = false; - } - } - menu.setGroupCheckable(1, true, true); - - restoreFilterChecked(menu, filterItemCheckedId); - } - - @Override - public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, filter); - return true; - } - - private void restoreFilterChecked(final Menu menu, final int itemId) { - if (itemId != -1) { - final MenuItem item = menu.findItem(itemId); - if (item == null) { - return; - } - - item.setChecked(true); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Search - //////////////////////////////////////////////////////////////////////////*/ - - private void showSearchOnStart() { - if (DEBUG) { - Log.d(TAG, "showSearchOnStart() called, searchQuery → " - + searchString - + ", lastSearchedQuery → " - + lastSearchedString); - } - searchEditText.setText(searchString); - - if (TextUtils.isEmpty(searchString) - || isSearchEditBlank()) { - searchToolbarContainer.setTranslationX(100); - searchToolbarContainer.setAlpha(0.0f); - searchToolbarContainer.setVisibility(View.VISIBLE); - searchToolbarContainer.animate() - .translationX(0) - .alpha(1.0f) - .setDuration(200) - .setInterpolator(new DecelerateInterpolator()).start(); - } else { - searchToolbarContainer.setTranslationX(0); - searchToolbarContainer.setAlpha(1.0f); - searchToolbarContainer.setVisibility(View.VISIBLE); - } - } - - private void initSearchListeners() { - if (DEBUG) { - Log.d(TAG, "initSearchListeners() called"); - } - searchClear.setOnClickListener(v -> { - if (DEBUG) { - Log.d(TAG, "onClick() called with: v = [" + v + "]"); - } - if (isSearchEditBlank()) { - NavigationHelper.gotoMainFragment(getFM()); - return; - } - - searchBinding.correctSuggestion.setVisibility(View.GONE); - - searchEditText.setText(""); - suggestionListAdapter.submitList(null); - showKeyboardSearch(); - }); - - TooltipCompat.setTooltipText(searchClear, getString(R.string.clear)); - - searchEditText.setOnClickListener(v -> { - if (DEBUG) { - Log.d(TAG, "onClick() called with: v = [" + v + "]"); - } - if ((showLocalSuggestions || showRemoteSuggestions) && !isErrorPanelVisible()) { - showSuggestionsPanel(); - } - if (DeviceUtils.isTv(getContext())) { - showKeyboardSearch(); - } - }); - - searchEditText.setOnFocusChangeListener((final View v, final boolean hasFocus) -> { - if (DEBUG) { - Log.d(TAG, "onFocusChange() called with: " - + "v = [" + v + "], hasFocus = [" + hasFocus + "]"); - } - if ((showLocalSuggestions || showRemoteSuggestions) - && hasFocus && !isErrorPanelVisible()) { - showSuggestionsPanel(); - } - }); - - suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { - @Override - public void onSuggestionItemSelected(final SuggestionItem item) { - search(item.query, new String[0], ""); - searchEditText.setText(item.query); - } - - @Override - public void onSuggestionItemInserted(final SuggestionItem item) { - searchEditText.setText(item.query); - searchEditText.setSelection(searchEditText.getText().length()); - } - - @Override - public void onSuggestionItemLongClick(final SuggestionItem item) { - if (item.fromHistory) { - showDeleteSuggestionDialog(item); - } - } - }); - - if (textWatcher != null) { - searchEditText.removeTextChangedListener(textWatcher); - } - textWatcher = new TextWatcher() { - @Override - public void beforeTextChanged(final CharSequence s, final int start, - final int count, final int after) { - // Do nothing, old text is already clean - } - - @Override - public void onTextChanged(final CharSequence s, final int start, - final int before, final int count) { - // Changes are handled in afterTextChanged; CharSequence cannot be changed here. - } - - @Override - public void afterTextChanged(final Editable s) { - // Remove rich text formatting - for (final CharacterStyle span : s.getSpans(0, s.length(), CharacterStyle.class)) { - s.removeSpan(span); - } - - final String newText = getSearchEditString().trim(); - suggestionPublisher.onNext(newText); - } - }; - searchEditText.addTextChangedListener(textWatcher); - searchEditText.setOnEditorActionListener( - (final TextView v, final int actionId, final KeyEvent event) -> { - if (DEBUG) { - Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " - + "actionId = [" + actionId + "], event = [" + event + "]"); - } - if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { - hideKeyboardSearch(); - } else if (event != null - && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER - || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { - searchEditText.setText(getSearchEditString().trim()); - search(getSearchEditString(), new String[0], ""); - return true; - } - return false; - }); - - if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { - initSuggestionObserver(); - } - } - - private void unsetSearchListeners() { - if (DEBUG) { - Log.d(TAG, "unsetSearchListeners() called"); - } - searchClear.setOnClickListener(null); - searchClear.setOnLongClickListener(null); - searchEditText.setOnClickListener(null); - searchEditText.setOnFocusChangeListener(null); - searchEditText.setOnEditorActionListener(null); - - if (textWatcher != null) { - searchEditText.removeTextChangedListener(textWatcher); - } - textWatcher = null; - } - - private void showSuggestionsPanel() { - if (DEBUG) { - Log.d(TAG, "showSuggestionsPanel() called"); - } - suggestionsPanelVisible = true; - animate(searchBinding.suggestionsPanel, true, 200, - AnimationType.LIGHT_SLIDE_AND_ALPHA); - } - - private void hideSuggestionsPanel() { - if (DEBUG) { - Log.d(TAG, "hideSuggestionsPanel() called"); - } - suggestionsPanelVisible = false; - animate(searchBinding.suggestionsPanel, false, 200, - AnimationType.LIGHT_SLIDE_AND_ALPHA); - } - - private void showKeyboardSearch() { - if (DEBUG) { - Log.d(TAG, "showKeyboardSearch() called"); - } - KeyboardUtil.showKeyboard(activity, searchEditText); - } - - private void hideKeyboardSearch() { - if (DEBUG) { - Log.d(TAG, "hideKeyboardSearch() called"); - } - - KeyboardUtil.hideKeyboard(activity, searchEditText); - } - - private void showDeleteSuggestionDialog(final SuggestionItem item) { - if (activity == null || historyRecordManager == null || searchEditText == null) { - return; - } - final String query = item.query; - new AlertDialog.Builder(activity) - .setTitle(query) - .setMessage(R.string.delete_item_search_history) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, (dialog, which) -> { - final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> suggestionPublisher - .onNext(getSearchEditString()), - throwable -> showSnackBarError(new ErrorInfo(throwable, - UserAction.DELETE_FROM_HISTORY, - "Deleting item failed"))); - disposables.add(onDelete); - }) - .show(); - } - - @Override - public boolean onBackPressed() { - if (suggestionsPanelVisible - && !infoListAdapter.getItemsList().isEmpty() - && !isLoading.get()) { - hideSuggestionsPanel(); - hideKeyboardSearch(); - searchEditText.setText(lastSearchedString); - return true; - } - return false; - } - - - private Observable> getLocalSuggestionsObservable( - final String query, final int similarQueryLimit) { - return historyRecordManager - .getRelatedSearches(query, similarQueryLimit, 25) - .toObservable() - .map(searchHistoryEntries -> - searchHistoryEntries.stream() - .map(entry -> new SuggestionItem(true, entry)) - .collect(Collectors.toList())); - } - - private Observable> getRemoteSuggestionsObservable(final String query) { - return ExtractorHelper - .suggestionsFor(serviceId, query) - .toObservable() - .map(strings -> { - final List result = new ArrayList<>(); - for (final String entry : strings) { - result.add(new SuggestionItem(false, entry)); - } - return result; - }); - } - - private void initSuggestionObserver() { - if (DEBUG) { - Log.d(TAG, "initSuggestionObserver() called"); - } - if (suggestionDisposable != null) { - suggestionDisposable.dispose(); - } - - suggestionDisposable = suggestionPublisher - .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) - .startWithItem(searchString == null ? "" : searchString) - .switchMap(query -> { - // Only show remote suggestions if they are enabled in settings and - // the query length is at least THRESHOLD_NETWORK_SUGGESTION - final boolean shallShowRemoteSuggestionsNow = showRemoteSuggestions - && query.length() >= THRESHOLD_NETWORK_SUGGESTION; - - if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { - return Observable.zip( - getLocalSuggestionsObservable(query, 3), - getRemoteSuggestionsObservable(query), - (local, remote) -> { - remote.removeIf(remoteItem -> local.stream().anyMatch( - localItem -> localItem.equals(remoteItem))); - local.addAll(remote); - return local; - }) - .materialize(); - } else if (showLocalSuggestions) { - return getLocalSuggestionsObservable(query, 25) - .materialize(); - } else if (shallShowRemoteSuggestionsNow) { - return getRemoteSuggestionsObservable(query) - .materialize(); - } else { - return Single.fromCallable(Collections::emptyList) - .toObservable() - .materialize(); - } - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - listNotification -> { - if (listNotification.isOnNext()) { - if (listNotification.getValue() != null) { - handleSuggestions(listNotification.getValue()); - } - } else if (listNotification.isOnError() - && listNotification.getError() != null - && !ExceptionUtils.isInterruptedCaused( - listNotification.getError())) { - showSnackBarError(new ErrorInfo(listNotification.getError(), - UserAction.GET_SUGGESTIONS, searchString, serviceId)); - } - }, throwable -> showSnackBarError(new ErrorInfo( - throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); - } - - @Override - protected void doInitialLoadLogic() { - // no-op - } - - /** - * Perform a search. - * @param theSearchString the trimmed search string - * @param theContentFilter the content filter to use. FIXME: unused param - * @param theSortFilter FIXME: unused param - */ - private void search(@NonNull final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { - if (DEBUG) { - Log.d(TAG, "search() called with: query = [" + theSearchString + "]"); - } - if (theSearchString.isEmpty()) { - return; - } - - // Check if theSearchString is a URL which can be opened by NewPipe directly - // and open it if possible. - try { - final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString); - showLoading(); - disposables.add(Observable - .fromCallable(() -> NavigationHelper.getIntentByLink(activity, - streamingService, theSearchString)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(intent -> { - getFM().popBackStackImmediate(); - activity.startActivity(intent); - }, throwable -> showTextError(getString(R.string.unsupported_url)))); - return; - } catch (final Exception ignored) { - // Exception occurred, it's not a url - } - - // prepare search - lastSearchedString = this.searchString; - this.searchString = theSearchString; - infoListAdapter.clearStreamItemList(); - hideSuggestionsPanel(); - showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, - searchBinding.searchMetaInfoSeparator, disposables); - hideKeyboardSearch(); - - // store search query if search history is enabled - disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - ignored -> { - }, - throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, - theSearchString, serviceId)) - )); - - // load search results - suggestionPublisher.onNext(theSearchString); - startLoading(false); - } - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - disposables.clear(); - if (searchDisposable != null) { - searchDisposable.dispose(); - } - searchDisposable = ExtractorHelper.searchFor(serviceId, - searchString, - Arrays.asList(contentFilter), - sortFilter) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnEvent((searchResult, throwable) -> isLoading.set(false)) - .subscribe(this::handleResult, this::onItemError); - - } - - @Override - protected void loadMoreItems() { - if (!Page.isValid(nextPage)) { - return; - } - isLoading.set(true); - showListFooter(true); - if (searchDisposable != null) { - searchDisposable.dispose(); - } - searchDisposable = ExtractorHelper.getMoreSearchItems( - serviceId, - searchString, - asList(contentFilter), - sortFilter, - nextPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) - .subscribe(this::handleNextItems, this::onItemError); - } - - @Override - protected boolean hasMoreItems() { - return Page.isValid(nextPage); - } - - @Override - protected void onItemSelected(final InfoItem selectedItem) { - super.onItemSelected(selectedItem); - hideKeyboardSearch(); - } - - private void onItemError(final Throwable exception) { - if (exception instanceof SearchExtractor.NothingFoundException) { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - } else { - showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId, - getOpenInBrowserUrlForErrors())); - } - } - - @Nullable - private String getOpenInBrowserUrlForErrors() { - if (TextUtils.isEmpty(searchString)) { - return null; - } - try { - return service.getSearchQHFactory().getUrl(searchString, - Arrays.asList(contentFilter), sortFilter); - } catch (final NullPointerException | ParsingException ignored) { - return null; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void changeContentFilter(final MenuItem item, final List theContentFilter) { - filterItemCheckedId = item.getItemId(); - item.setChecked(true); - - if (service != null) { - final boolean isNotFiltered = theContentFilter.isEmpty() - || "all".equals(theContentFilter.get(0)); - if (isNotFiltered) { - searchEditText.setHint( - getString(R.string.search_with_service_name, - service.getServiceInfo().getName())); - } else { - searchEditText.setHint(getString(R.string.search_with_service_name_and_filter, - service.getServiceInfo().getName(), - item.getTitle())); - } - } - - contentFilter = theContentFilter.toArray(new String[0]); - - if (!TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); - } - } - - private void setQuery(final int theServiceId, - final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { - serviceId = theServiceId; - searchString = theSearchString; - contentFilter = theContentFilter; - sortFilter = theSortFilter; - } - - private String getSearchEditString() { - return searchEditText.getText().toString(); - } - - private boolean isSearchEditBlank() { - return isBlank(getSearchEditString()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Suggestion Results - //////////////////////////////////////////////////////////////////////////*/ - - public void handleSuggestions(@NonNull final List suggestions) { - if (DEBUG) { - Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]"); - } - suggestionListAdapter.submitList(suggestions, - () -> { - if (searchBinding != null) { - searchBinding.suggestionsList.scrollToPosition(0); - } - }); - - if (suggestionsPanelVisible && isErrorPanelVisible()) { - hideLoading(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void hideLoading() { - super.hideLoading(); - showListFooter(false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Search Results - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void handleResult(@NonNull final SearchInfo result) { - final List exceptions = result.getErrors(); - if (!exceptions.isEmpty() - && !(exceptions.size() == 1 - && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, - searchString, serviceId, getOpenInBrowserUrlForErrors())); - } - - searchSuggestion = result.getSearchSuggestion(); - if (searchSuggestion != null) { - searchSuggestion = searchSuggestion.trim(); - } - isCorrectedSearch = result.isCorrectedSearch(); - - // List cannot be bundled without creating some containers - metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]); - showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, - searchBinding.searchMetaInfoSeparator, disposables); - - handleSearchSuggestion(); - - lastSearchedString = searchString; - nextPage = result.getNextPage(); - - if (infoListAdapter.getItemsList().isEmpty()) { - if (!result.getRelatedItems().isEmpty()) { - infoListAdapter.addInfoItemList(result.getRelatedItems()); - } else { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - return; - } - } - - super.handleResult(result); - } - - private void handleSearchSuggestion() { - if (TextUtils.isEmpty(searchSuggestion)) { - searchBinding.correctSuggestion.setVisibility(View.GONE); - } else { - final String helperText = getString(isCorrectedSearch - ? R.string.search_showing_result_for - : R.string.did_you_mean); - - final String highlightedSearchSuggestion = - "" + Html.escapeHtml(searchSuggestion) + ""; - final String text = String.format(helperText, highlightedSearchSuggestion); - searchBinding.correctSuggestion.setText(HtmlCompat.fromHtml(text, - HtmlCompat.FROM_HTML_MODE_LEGACY)); - - searchBinding.correctSuggestion.setOnClickListener(v -> { - searchBinding.correctSuggestion.setVisibility(View.GONE); - search(searchSuggestion, contentFilter, sortFilter); - searchEditText.setText(searchSuggestion); - }); - - searchBinding.correctSuggestion.setOnLongClickListener(v -> { - searchEditText.setText(searchSuggestion); - searchEditText.setSelection(searchSuggestion.length()); - showKeyboardSearch(); - return true; - }); - - searchBinding.correctSuggestion.setVisibility(View.VISIBLE); - } - } - - @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { - showListFooter(false); - infoListAdapter.addInfoItemList(result.getItems()); - - if (!result.getErrors().isEmpty()) { - // nextPage should be non-null at this point, because it refers to the page - // whose results are handled here, but let's check it anyway - if (nextPage == null) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, - "\"" + searchString + "\" → nextPage == null", serviceId, - getOpenInBrowserUrlForErrors())); - } else { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, - "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " - + "pageIds: " + nextPage.getIds() + ", " - + "pageCookies: " + nextPage.getCookies(), - serviceId, getOpenInBrowserUrlForErrors())); - } - } - - // keep the reassignment of nextPage after the error handling to ensure that nextPage - // still holds the correct value during the error handling - nextPage = result.getNextPage(); - super.handleNextItems(result); - } - - @Override - public void handleError() { - super.handleError(); - hideSuggestionsPanel(); - hideKeyboardSearch(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Suggestion item touch helper - //////////////////////////////////////////////////////////////////////////*/ - - public int getSuggestionMovementFlags(@NonNull final RecyclerView.ViewHolder viewHolder) { - final int position = viewHolder.getBindingAdapterPosition(); - if (position == RecyclerView.NO_POSITION) { - return 0; - } - - final SuggestionItem item = suggestionListAdapter.getCurrentList().get(position); - return item.fromHistory ? makeMovementFlags(0, - ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0; - } - - public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder) { - final int position = viewHolder.getBindingAdapterPosition(); - final String query = suggestionListAdapter.getCurrentList().get(position).query; - final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> suggestionPublisher - .onNext(getSearchEditString()), - throwable -> showSnackBarError(new ErrorInfo(throwable, - UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); - disposables.add(onDelete); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt deleted file mode 100644 index 1317f9acb..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.fragments.list.search - -class SuggestionItem(@JvmField val fromHistory: Boolean, @JvmField val query: String) { - override fun equals(other: Any?): Boolean { - if (other is SuggestionItem) { - return query == other.query - } - return false - } - - override fun hashCode() = query.hashCode() - - override fun toString() = "[$fromHistory→$query]" -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt deleted file mode 100644 index 4eb4c1574..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.fragments.list.search - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding -import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder - -class SuggestionListAdapter : - ListAdapter(SuggestionItemCallback()) { - - var listener: OnSuggestionItemSelected? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionItemHolder { - return SuggestionItemHolder( - ItemSearchSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) { - val currentItem = getItem(position) - holder.updateFrom(currentItem) - holder.binding.suggestionSearch.setOnClickListener { - listener?.onSuggestionItemSelected(currentItem) - } - holder.binding.suggestionSearch.setOnLongClickListener { - listener?.onSuggestionItemLongClick(currentItem) - true - } - holder.binding.suggestionInsert.setOnClickListener { - listener?.onSuggestionItemInserted(currentItem) - } - } - - interface OnSuggestionItemSelected { - fun onSuggestionItemSelected(item: SuggestionItem) - - fun onSuggestionItemInserted(item: SuggestionItem) - - fun onSuggestionItemLongClick(item: SuggestionItem) - } - - class SuggestionItemHolder(val binding: ItemSearchSuggestionBinding) : - RecyclerView.ViewHolder(binding.getRoot()) { - fun updateFrom(item: SuggestionItem) { - binding.itemSuggestionIcon.setImageResource( - if (item.fromHistory) { - R.drawable.ic_history - } else { - R.drawable.ic_search - } - ) - binding.itemSuggestionQuery.text = item.query - } - } - - private class SuggestionItemCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean { - return oldItem.fromHistory == newItem.fromHistory && oldItem.query == newItem.query - } - - override fun areContentsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean { - return true // items' contents never change; the list of items themselves does - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java deleted file mode 100644 index 39d145b1d..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java +++ /dev/null @@ -1,202 +0,0 @@ -package org.schabi.newpipe.fragments.list.videos; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.ktx.ViewUtils; - -import java.io.Serializable; -import java.util.function.Supplier; - -import io.reactivex.rxjava3.core.Single; - -public class RelatedItemsFragment extends BaseListInfoFragment - implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final String INFO_KEY = "related_info_key"; - - private RelatedItemsInfo relatedItemsInfo; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private RelatedItemsHeaderBinding headerBinding; - - public static RelatedItemsFragment getInstance(final StreamInfo info) { - final RelatedItemsFragment instance = new RelatedItemsFragment(); - instance.setInitialData(info); - return instance; - } - - public RelatedItemsFragment() { - super(UserAction.REQUESTED_STREAM); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_related_items, container, false); - } - - @Override - public void onDestroyView() { - headerBinding = null; - super.onDestroyView(); - } - - @Override - protected Supplier getListHeaderSupplier() { - if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { - return null; - } - - headerBinding = RelatedItemsHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - - final SharedPreferences pref = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false); - headerBinding.autoplaySwitch.setChecked(autoplay); - headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) -> - PreferenceManager.getDefaultSharedPreferences(requireContext()).edit() - .putBoolean(getString(R.string.auto_queue_key), b).apply()); - - return headerBinding::getRoot; - } - - @Override - protected Single> loadMoreItemsLogic() { - return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> relatedItemsInfo); - } - - @Override - public void showLoading() { - super.showLoading(); - if (headerBinding != null) { - headerBinding.getRoot().setVisibility(View.INVISIBLE); - } - } - - @Override - public void handleResult(@NonNull final RelatedItemsInfo result) { - super.handleResult(result); - - if (headerBinding != null) { - headerBinding.getRoot().setVisibility(View.VISIBLE); - } - ViewUtils.slideUp(requireView(), 120, 96, 0.06f); - - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { - // Nothing to do - override parent - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - // Nothing to do - override parent - } - - private void setInitialData(final StreamInfo info) { - super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); - if (this.relatedItemsInfo == null) { - this.relatedItemsInfo = new RelatedItemsInfo(info); - } - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - outState.putSerializable(INFO_KEY, relatedItemsInfo); - } - - @Override - protected void onRestoreInstanceState(@NonNull final Bundle savedState) { - super.onRestoreInstanceState(savedState); - final Serializable serializable = savedState.getSerializable(INFO_KEY); - if (serializable instanceof RelatedItemsInfo) { - this.relatedItemsInfo = (RelatedItemsInfo) serializable; - } - } - - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) { - headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false)); - } - } - - @Override - protected ItemViewMode getItemViewMode() { - ItemViewMode mode = super.getItemViewMode(); - // Only list mode is supported. Either List or card will be used. - if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) { - mode = ItemViewMode.LIST; - } - return mode; - } - - @Override - protected void showInfoItemDialog(final StreamInfoItem item) { - // Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment - // so that its context is not lost when the RelatedItemsFragment is reinitialized, - // e.g. when a new stream is loaded in a parent VideoDetailFragment. - final Fragment parentFragment = getParentFragment(); - if (parentFragment != null) { - try { - new InfoItemDialog.Builder( - parentFragment.getActivity(), - parentFragment.getContext(), - parentFragment, - item - ).create().show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } else { - super.showInfoItemDialog(item); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java deleted file mode 100644 index bbc7e1ed0..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.fragments.list.videos; - -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.stream.StreamInfo; - -import java.util.ArrayList; -import java.util.Collections; - -public final class RelatedItemsInfo extends ListInfo { - /** - * This class is used to wrap the related items of a StreamInfo into a ListInfo object. - * - * @param info the stream info from which to get related items - */ - public RelatedItemsInfo(final StreamInfo info) { - super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(), - info.getId(), Collections.emptyList(), null), info.getName()); - setRelatedItems(new ArrayList<>(info.getRelatedItems())); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt deleted file mode 100644 index cf674180e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2016-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.info_list - -import android.content.Context -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.extractor.comments.CommentsInfoItem -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.util.OnClickGesture - -class InfoItemBuilder(val context: Context) { - var onStreamSelectedListener: OnClickGesture? = null - var onChannelSelectedListener: OnClickGesture? = null - var onPlaylistSelectedListener: OnClickGesture? = null - var onCommentsSelectedListener: OnClickGesture? = null -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java deleted file mode 100644 index 575568c00..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ /dev/null @@ -1,358 +0,0 @@ -package org.schabi.newpipe.info_list; - -import android.content.Context; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.databinding.PignateFooterBinding; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder; -import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; -import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; -import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; -import org.schabi.newpipe.info_list.holder.InfoItemHolder; -import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; -import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; -import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; -import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder; -import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder; -import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; -import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.FallbackViewHolder; -import org.schabi.newpipe.util.OnClickGesture; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Supplier; - -/* - * Created by Christian Schabesberger on 01.08.16. - * - * Copyright (C) Christian Schabesberger 2016 - * InfoListAdapter.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class InfoListAdapter extends RecyclerView.Adapter { - private static final String TAG = InfoListAdapter.class.getSimpleName(); - private static final boolean DEBUG = false; - - private static final int HEADER_TYPE = 0; - private static final int FOOTER_TYPE = 1; - - private static final int MINI_STREAM_HOLDER_TYPE = 0x100; - private static final int STREAM_HOLDER_TYPE = 0x101; - private static final int GRID_STREAM_HOLDER_TYPE = 0x102; - private static final int CARD_STREAM_HOLDER_TYPE = 0x103; - private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200; - private static final int CHANNEL_HOLDER_TYPE = 0x201; - private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202; - private static final int CARD_CHANNEL_HOLDER_TYPE = 0x203; - private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300; - private static final int PLAYLIST_HOLDER_TYPE = 0x301; - private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; - private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303; - private static final int COMMENT_HOLDER_TYPE = 0x400; - - private final LayoutInflater layoutInflater; - private final InfoItemBuilder infoItemBuilder; - private final List infoItemList; - private final HistoryRecordManager recordManager; - - private boolean useMiniVariant = false; - private boolean showFooter = false; - - private ItemViewMode itemMode = ItemViewMode.LIST; - - private Supplier headerSupplier = null; - - public InfoListAdapter(final Context context) { - layoutInflater = LayoutInflater.from(context); - recordManager = new HistoryRecordManager(context); - infoItemBuilder = new InfoItemBuilder(context); - infoItemList = new ArrayList<>(); - } - - public void setOnStreamSelectedListener(final OnClickGesture listener) { - infoItemBuilder.setOnStreamSelectedListener(listener); - } - - public void setOnChannelSelectedListener(final OnClickGesture listener) { - infoItemBuilder.setOnChannelSelectedListener(listener); - } - - public void setOnPlaylistSelectedListener(final OnClickGesture listener) { - infoItemBuilder.setOnPlaylistSelectedListener(listener); - } - - public void setOnCommentsSelectedListener(final OnClickGesture listener) { - infoItemBuilder.setOnCommentsSelectedListener(listener); - } - - public void setUseMiniVariant(final boolean useMiniVariant) { - this.useMiniVariant = useMiniVariant; - } - - public void setItemViewMode(final ItemViewMode itemViewMode) { - this.itemMode = itemViewMode; - } - - public void addInfoItemList(@Nullable final List data) { - if (data == null) { - return; - } - if (DEBUG) { - Log.d(TAG, "addInfoItemList() before > infoItemList.size() = " - + infoItemList.size() + ", data.size() = " + data.size()); - } - - final int offsetStart = sizeConsideringHeaderOffset(); - infoItemList.addAll(data); - - if (DEBUG) { - Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", " - + "infoItemList.size() = " + infoItemList.size() + ", " - + "hasHeader = " + hasHeader() + ", " - + "showFooter = " + showFooter); - } - notifyItemRangeInserted(offsetStart, data.size()); - - if (showFooter) { - final int footerNow = sizeConsideringHeaderOffset(); - notifyItemMoved(offsetStart, footerNow); - - if (DEBUG) { - Log.d(TAG, "addInfoItemList() footer from " + offsetStart - + " to " + footerNow); - } - } - } - - public void clearStreamItemList() { - if (infoItemList.isEmpty()) { - return; - } - infoItemList.clear(); - notifyDataSetChanged(); - } - - public void setHeaderSupplier(@Nullable final Supplier headerSupplier) { - final boolean changed = headerSupplier != this.headerSupplier; - this.headerSupplier = headerSupplier; - if (changed) { - notifyDataSetChanged(); - } - } - - protected boolean hasHeader() { - return this.headerSupplier != null; - } - - public void showFooter(final boolean show) { - if (DEBUG) { - Log.d(TAG, "showFooter() called with: show = [" + show + "]"); - } - if (show == showFooter) { - return; - } - - showFooter = show; - if (show) { - notifyItemInserted(sizeConsideringHeaderOffset()); - } else { - notifyItemRemoved(sizeConsideringHeaderOffset()); - } - } - - private int sizeConsideringHeaderOffset() { - final int i = infoItemList.size() + (hasHeader() ? 1 : 0); - if (DEBUG) { - Log.d(TAG, "sizeConsideringHeaderOffset() called → " + i); - } - return i; - } - - public List getItemsList() { - return infoItemList; - } - - @Override - public int getItemCount() { - int count = infoItemList.size(); - if (hasHeader()) { - count++; - } - if (showFooter) { - count++; - } - - if (DEBUG) { - Log.d(TAG, "getItemCount() called with: " - + "count = " + count + ", infoItemList.size() = " + infoItemList.size() + ", " - + "hasHeader = " + hasHeader() + ", " - + "showFooter = " + showFooter); - } - return count; - } - - @SuppressWarnings("FinalParameters") - @Override - public int getItemViewType(int position) { - if (DEBUG) { - Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); - } - - if (hasHeader() && position == 0) { - return HEADER_TYPE; - } else if (hasHeader()) { - position--; - } - if (position == infoItemList.size() && showFooter) { - return FOOTER_TYPE; - } - final InfoItem item = infoItemList.get(position); - switch (item.getInfoType()) { - case STREAM: - if (itemMode == ItemViewMode.CARD) { - return CARD_STREAM_HOLDER_TYPE; - } else if (itemMode == ItemViewMode.GRID) { - return GRID_STREAM_HOLDER_TYPE; - } else if (useMiniVariant) { - return MINI_STREAM_HOLDER_TYPE; - } else { - return STREAM_HOLDER_TYPE; - } - case CHANNEL: - if (itemMode == ItemViewMode.CARD) { - return CARD_CHANNEL_HOLDER_TYPE; - } else if (itemMode == ItemViewMode.GRID) { - return GRID_CHANNEL_HOLDER_TYPE; - } else if (useMiniVariant) { - return MINI_CHANNEL_HOLDER_TYPE; - } else { - return CHANNEL_HOLDER_TYPE; - } - case PLAYLIST: - if (itemMode == ItemViewMode.CARD) { - return CARD_PLAYLIST_HOLDER_TYPE; - } else if (itemMode == ItemViewMode.GRID) { - return GRID_PLAYLIST_HOLDER_TYPE; - } else if (useMiniVariant) { - return MINI_PLAYLIST_HOLDER_TYPE; - } else { - return PLAYLIST_HOLDER_TYPE; - } - case COMMENT: - return COMMENT_HOLDER_TYPE; - default: - return -1; - } - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int type) { - if (DEBUG) { - Log.d(TAG, "onCreateViewHolder() called with: " - + "parent = [" + parent + "], type = [" + type + "]"); - } - switch (type) { - // #4475 and #3368 - // Always create a new instance otherwise the same instance - // is sometimes reused which causes a crash - case HEADER_TYPE: - return new HFHolder(headerSupplier.get()); - case FOOTER_TYPE: - return new HFHolder(PignateFooterBinding - .inflate(layoutInflater, parent, false) - .getRoot() - ); - case MINI_STREAM_HOLDER_TYPE: - return new StreamMiniInfoItemHolder(infoItemBuilder, parent); - case STREAM_HOLDER_TYPE: - return new StreamInfoItemHolder(infoItemBuilder, parent); - case GRID_STREAM_HOLDER_TYPE: - return new StreamGridInfoItemHolder(infoItemBuilder, parent); - case CARD_STREAM_HOLDER_TYPE: - return new StreamCardInfoItemHolder(infoItemBuilder, parent); - case MINI_CHANNEL_HOLDER_TYPE: - return new ChannelMiniInfoItemHolder(infoItemBuilder, parent); - case CHANNEL_HOLDER_TYPE: - return new ChannelInfoItemHolder(infoItemBuilder, parent); - case CARD_CHANNEL_HOLDER_TYPE: - return new ChannelCardInfoItemHolder(infoItemBuilder, parent); - case GRID_CHANNEL_HOLDER_TYPE: - return new ChannelGridInfoItemHolder(infoItemBuilder, parent); - case MINI_PLAYLIST_HOLDER_TYPE: - return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent); - case PLAYLIST_HOLDER_TYPE: - return new PlaylistInfoItemHolder(infoItemBuilder, parent); - case GRID_PLAYLIST_HOLDER_TYPE: - return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); - case CARD_PLAYLIST_HOLDER_TYPE: - return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); - case COMMENT_HOLDER_TYPE: - return new CommentInfoItemHolder(infoItemBuilder, parent); - default: - return new FallbackViewHolder(new View(parent.getContext())); - } - } - - @Override - public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, - final int position) { - if (DEBUG) { - Log.d(TAG, "onBindViewHolder() called with: " - + "holder = [" + holder.getClass().getSimpleName() + "], " - + "position = [" + position + "]"); - } - if (holder instanceof InfoItemHolder) { - ((InfoItemHolder) holder).updateFromItem( - // If header is present, offset the items by -1 - infoItemList.get(hasHeader() ? position - 1 : position), recordManager); - } - } - - public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { - return new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(final int position) { - final int type = getItemViewType(position); - return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; - } - }; - } - - static class HFHolder extends RecyclerView.ViewHolder { - HFHolder(final View v) { - super(v); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt deleted file mode 100644 index 899223afa..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.info_list - -/** - * Item view mode for streams & playlist listing screens. - */ -enum class ItemViewMode { - /** - * Default mode. - */ - AUTO, - - /** - * Full width list item with thumb on the left and two line title & uploader in right. - */ - LIST, - - /** - * Grid mode places two cards per row. - */ - GRID, - - /** - * A full width card in phone - portrait. - */ - CARD -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt deleted file mode 100644 index 9b6005f65..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.schabi.newpipe.info_list - -import android.util.Log -import com.xwray.groupie.GroupieAdapter -import kotlin.math.max -import org.schabi.newpipe.extractor.stream.StreamInfo - -/** - * Custom RecyclerView.Adapter/GroupieAdapter for [StreamSegmentItem] for handling selection state. - */ -class StreamSegmentAdapter( - private val listener: StreamSegmentListener -) : GroupieAdapter() { - - var currentIndex: Int = 0 - private set - - /** - * Returns `true` if the provided [StreamInfo] contains segments, `false` otherwise. - */ - fun setItems(info: StreamInfo): Boolean { - if (info.streamSegments.isNotEmpty()) { - clear() - addAll(info.streamSegments.map { StreamSegmentItem(it, listener) }) - return true - } - return false - } - - fun selectSegment(segment: StreamSegmentItem) { - unSelectCurrentSegment() - currentIndex = max(0, getAdapterPosition(segment)) - segment.isSelected = true - segment.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) - } - - fun selectSegmentAt(position: Int) { - try { - selectSegment(getGroupAtAdapterPosition(position) as StreamSegmentItem) - } catch (e: IndexOutOfBoundsException) { - // Just to make sure that getGroupAtAdapterPosition doesn't close the app - // Shouldn't happen since setItems is always called before select-methods but just in case - currentIndex = 0 - Log.e("StreamSegmentAdapter", "selectSegmentAt: ${e.message}") - } - } - - private fun unSelectCurrentSegment() { - try { - val segmentItem = getGroupAtAdapterPosition(currentIndex) as StreamSegmentItem - currentIndex = 0 - segmentItem.isSelected = false - segmentItem.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) - } catch (e: IndexOutOfBoundsException) { - // Just to make sure that getGroupAtAdapterPosition doesn't close the app - // Shouldn't happen since setItems is always called before select-methods but just in case - currentIndex = 0 - Log.e("StreamSegmentAdapter", "unSelectCurrentSegment: ${e.message}") - } - } - - interface StreamSegmentListener { - fun onItemClick(item: StreamSegmentItem, seconds: Int) - fun onItemLongClick(item: StreamSegmentItem, seconds: Int) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt deleted file mode 100644 index 7276051e8..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.schabi.newpipe.info_list - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ItemStreamSegmentBinding -import org.schabi.newpipe.extractor.stream.StreamSegment -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.image.CoilHelper - -class StreamSegmentItem( - private val item: StreamSegment, - private val onClick: StreamSegmentAdapter.StreamSegmentListener -) : BindableItem() { - - companion object { - const val PAYLOAD_SELECT = 1 - } - - var isSelected = false - - override fun bind(viewBinding: ItemStreamSegmentBinding, position: Int) { - CoilHelper.loadThumbnail(viewBinding.previewImage, item.previewUrl) - viewBinding.textViewTitle.text = item.title - if (item.channelName == null) { - viewBinding.textViewChannel.visibility = View.GONE - // When the channel name is displayed there is less space - // and thus the segment title needs to be only one line height. - // But when there is no channel name displayed, the title can be two lines long. - // The default maxLines value is set to 1 to display all elements in the AS preview, - viewBinding.textViewTitle.maxLines = 2 - } else { - viewBinding.textViewChannel.text = item.channelName - viewBinding.textViewChannel.visibility = View.VISIBLE - } - viewBinding.textViewStartSeconds.text = - Localization.getDurationString(item.startTimeSeconds.toLong()) - viewBinding.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } - viewBinding.root.setOnLongClickListener { - onClick.onItemLongClick(this, item.startTimeSeconds) - true - } - viewBinding.root.isSelected = isSelected - } - - override fun bind( - viewHolder: GroupieViewHolder, - position: Int, - payloads: MutableList - ) { - if (payloads.contains(PAYLOAD_SELECT)) { - viewHolder.root.isSelected = isSelected - return - } - super.bind(viewHolder, position, payloads) - } - - override fun getLayout() = R.layout.item_stream_segment - - override fun initializeViewBinding(view: View) = ItemStreamSegmentBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java deleted file mode 100644 index dcf01e190..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ /dev/null @@ -1,356 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.external_communication.KoreUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; - -/** - * Dialog for a {@link StreamInfoItem}. - * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. - * This dialog is mostly used for longpress context menus. - */ -public final class InfoItemDialog { - private static final String TAG = Build.class.getSimpleName(); - /** - * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. - * However, extending {@link AlertDialog} requires many additional lines - * and brings more complexity to this class, especially the constructor. - * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. - * Its result is stored in this class variable to allow access via the {@link #show()} method. - */ - private final AlertDialog dialog; - - private InfoItemDialog(@NonNull final Activity activity, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem info, - @NonNull final List entries) { - - // Create the dialog's title - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - final TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(info.getName()); - - final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - if (info.getUploaderName() != null) { - detailsView.setText(info.getUploaderName()); - detailsView.setVisibility(View.VISIBLE); - } else { - detailsView.setVisibility(View.GONE); - } - - // Get the entry's descriptions which are displayed in the dialog - final String[] items = entries.stream() - .map(entry -> entry.getString(activity)).toArray(String[]::new); - - // Call an entry's action / onClick method when the entry is selected. - final DialogInterface.OnClickListener action = (d, index) -> - entries.get(index).action.onClick(fragment, info); - - dialog = new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(items, action) - .create(); - - } - - public void show() { - dialog.show(); - } - - /** - *

Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.

- * Use {@link #addEntry(StreamDialogDefaultEntry)} - * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. - *
- * Custom actions for entries can be set using - * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}. - */ - public static class Builder { - @NonNull private final Activity activity; - @NonNull private final Context context; - @NonNull private final StreamInfoItem infoItem; - @NonNull private final Fragment fragment; - @NonNull private final List entries = new ArrayList<>(); - private final boolean addDefaultEntriesAutomatically; - - /** - *

Create a {@link Builder builder} instance for a {@link StreamInfoItem} - * that automatically adds the some default entries - * at the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem the item for this dialog; all entries and their actions work with - * this {@link StreamInfoItem} - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem) { - this(activity, context, fragment, infoItem, true); - } - - /** - *

Create an instance of this {@link Builder} for a {@link StreamInfoItem}.

- *

If {@code addDefaultEntriesAutomatically} is set to {@code true}, - * some default entries are added to the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem - * @param addDefaultEntriesAutomatically - * whether default entries added with {@link #addDefaultBeginningEntries()} - * and {@link #addDefaultEndEntries()} are added automatically when generating - * the {@link InfoItemDialog}. - *
- * Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and - * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem, - final boolean addDefaultEntriesAutomatically) { - if (activity == null || context == null || context.getResources() == null) { - if (DEBUG) { - Log.d(TAG, "activity, context or resources is null: activity = " - + activity + ", context = " + context); - } - throw new IllegalArgumentException("activity, context or resources is null"); - } - this.activity = activity; - this.context = context; - this.fragment = fragment; - this.infoItem = infoItem; - this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically; - if (addDefaultEntriesAutomatically) { - addDefaultBeginningEntries(); - } - } - - /** - * Adds a new entry and appends it to the current entry list. - * @param entry the entry to add - * @return the current {@link Builder} instance - */ - public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) { - entries.add(entry.toStreamDialogEntry()); - return this; - } - - /** - * Adds new entries. These are appended to the current entry list. - * @param newEntries the entries to add - * @return the current {@link Builder} instance - */ - public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) { - Stream.of(newEntries).forEach(this::addEntry); - return this; - } - - /** - *

Change an entries' action that is called when the entry is selected.

- *

Warning: Only use this method when the entry has been already added. - * Changing the action of an entry which has not been added to the Builder yet - * does not have an effect.

- * @param entry the entry to change - * @param action the action to perform when the entry is selected - * @return the current {@link Builder} instance - */ - public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - for (int i = 0; i < entries.size(); i++) { - if (entries.get(i).resource == entry.resource) { - entries.set(i, new StreamDialogEntry(entry.resource, action)); - return this; - } - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and - * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams - * in the play queue. - * @return the current {@link Builder} instance - */ - public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.getInstance(); - if (holder.isPlayQueueReady()) { - addEntry(StreamDialogDefaultEntry.ENQUEUE); - - if (holder.getQueuePosition() < holder.getQueueSize() - 1) { - addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); - } - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. - * If the {@link #infoItem} is not a pure audio (live) stream, - * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. - * @return the current {@link Builder} instance - */ - public Builder addStartHereEntries() { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); - if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled - * and the stream is not a livestream. - * @return the current {@link Builder} instance - */ - public Builder addMarkAsWatchedEntryIfNeeded() { - final boolean isWatchHistoryEnabled = PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.enable_watch_history_key), false); - if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. - * @return the current {@link Builder} instance - */ - public Builder addPlayWithKodiEntryIfNeeded() { - if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { - addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); - } - return this; - } - - /** - * Add the entries which are usually at the top of the action list. - *
- * This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()}) - * and "start here" (see {@link #addStartHereEntries()} entries. - * @return the current {@link Builder} instance - */ - public Builder addDefaultBeginningEntries() { - addEnqueueEntriesIfNeeded(); - addStartHereEntries(); - return this; - } - - /** - * Add the entries which are usually at the bottom of the action list. - * @return the current {@link Builder} instance - */ - public Builder addDefaultEndEntries() { - addAllEntries( - StreamDialogDefaultEntry.DOWNLOAD, - StreamDialogDefaultEntry.APPEND_PLAYLIST, - StreamDialogDefaultEntry.SHARE, - StreamDialogDefaultEntry.OPEN_IN_BROWSER - ); - addPlayWithKodiEntryIfNeeded(); - addMarkAsWatchedEntryIfNeeded(); - addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS); - return this; - } - - /** - * Creates the {@link InfoItemDialog}. - * @return a new instance of {@link InfoItemDialog} - */ - public InfoItemDialog create() { - if (addDefaultEntriesAutomatically) { - addDefaultEndEntries(); - } - return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries); - } - - public static void reportErrorDuringInitialization(final Throwable throwable, - final InfoItem item) { - ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo( - throwable, - UserAction.OPEN_INFO_ITEM_DIALOG, - "none", - item.getServiceId())); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java deleted file mode 100644 index 2df11900e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ /dev/null @@ -1,176 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; -import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; - -import android.content.Context; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; - -/** - *

- * This enum provides entries that are accepted - * by the {@link InfoItemDialog.Builder}. - *

- *

- * These entries contain a String {@link #resource} which is displayed in the dialog and - * a default {@link #action} that is executed - * when the entry is selected (via onClick()). - *
- * They action can be overridden by using the Builder's - * {@link InfoItemDialog.Builder#setAction( - * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} - * method. - *

- */ -public enum StreamDialogDefaultEntry { - SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> - fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(), - item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url)) - ), - - /** - * Enqueues the stream automatically to the current PlayerType. - */ - ENQUEUE(R.string.enqueue_stream, (fragment, item) -> { - final Context ctx = fragment.requireContext().getApplicationContext(); - fetchItemInfoIfSparse(ctx, item, singlePlayQueue -> - NavigationHelper.enqueueOnPlayer(ctx, singlePlayQueue)); - }), - - /** - * Enqueues the stream automatically to the current PlayerType - * after the currently playing stream. - */ - ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) -> { - final Context ctx = fragment.requireContext().getApplicationContext(); - fetchItemInfoIfSparse(ctx, item, singlePlayQueue -> - NavigationHelper.enqueueNextOnPlayer(ctx, singlePlayQueue)); - }), - - START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) -> { - final Context ctx = fragment.requireContext().getApplicationContext(); - fetchItemInfoIfSparse(ctx, item, singlePlayQueue -> - NavigationHelper.playOnBackgroundPlayer(ctx, singlePlayQueue, true)); - }), - - START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) -> { - final Context ctx = fragment.requireContext().getApplicationContext(); - fetchItemInfoIfSparse(ctx, item, singlePlayQueue -> - NavigationHelper.playOnPopupPlayer(ctx, singlePlayQueue, true)); - }), - - SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - DELETE(R.string.delete, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - /** - * Opens a {@link PlaylistDialog} to either append the stream to a playlist - * or create a new playlist if there are no local playlists. - */ - APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> - PlaylistDialog.createCorrespondingDialog( - fragment.getContext(), - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragment.getParentFragmentManager(), - "StreamDialogEntry@" - + (dialog instanceof PlaylistAppendDialog ? "append" : "create") - + "_playlist" - ) - ) - ), - - PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> - KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))), - - SHARE(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), - item.getThumbnails())), - - /** - * Opens a {@link DownloadDialog} after fetching some stream info. - * If the user quits the current fragment, it will not open a DownloadDialog. - */ - DOWNLOAD(R.string.download, (fragment, item) -> - fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), - item.getUrl(), info -> { - // Ensure the fragment is attached and its state hasn't been saved to avoid - // showing dialog during lifecycle changes or when the activity is paused, - // e.g. by selecting the download option and opening a different fragment. - if (fragment.isAdded() && !fragment.isStateSaved()) { - final DownloadDialog downloadDialog = - new DownloadDialog(fragment.requireContext(), info); - downloadDialog.show(fragment.getChildFragmentManager(), - "downloadDialog"); - } - }) - ), - - OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> - ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), - - - MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) -> - new HistoryRecordManager(fragment.getContext()) - .markAsWatched(item) - .doOnError(error -> { - ErrorUtil.showSnackbar( - fragment.requireContext(), - new ErrorInfo( - error, - UserAction.OPEN_INFO_ITEM_DIALOG, - "Got an error when trying to mark as watched" - ) - ); - }) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - ); - - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntry.StreamDialogEntryAction action; - - StreamDialogDefaultEntry(@StringRes final int resource, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - @NonNull - public StreamDialogEntry toStreamDialogEntry() { - return new StreamDialogEntry(resource, action); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java deleted file mode 100644 index 9d82e3b58..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -public class StreamDialogEntry { - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntryAction action; - - public StreamDialogEntry(@StringRes final int resource, - @NonNull final StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - public String getString(@NonNull final Context context) { - return context.getString(resource); - } - - public interface StreamDialogEntryAction { - void onClick(Fragment fragment, StreamInfoItem infoItem); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java deleted file mode 100644 index 29fc50be0..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -public class ChannelCardInfoItemHolder extends ChannelMiniInfoItemHolder { - public ChannelCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_channel_card_item, parent); - } - - @Override - protected int getDescriptionMaxLineCount(@Nullable final String content) { - // Based on `list_channel_card_item` left side content (thumbnail 100dp - // + additional details), Right side description can grow up to 8 lines. - return 8; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java deleted file mode 100644 index a4755052e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -public class ChannelGridInfoItemHolder extends ChannelMiniInfoItemHolder { - public ChannelGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_channel_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java deleted file mode 100644 index f8133d3de..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -/* - * Created by Christian Schabesberger on 12.02.17. - * - * Copyright (C) Christian Schabesberger 2016 - * ChannelInfoItemHolder .java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { - public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_channel_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java deleted file mode 100644 index 92a5054e1..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.extractor.utils.Utils; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.CoilHelper; - -public class ChannelMiniInfoItemHolder extends InfoItemHolder { - private final ImageView itemThumbnailView; - private final TextView itemTitleView; - private final TextView itemAdditionalDetailView; - private final TextView itemChannelDescriptionView; - - ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails); - itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); - } - - public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_channel_mini_item, parent); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof ChannelInfoItem)) { - return; - } - final ChannelInfoItem item = (ChannelInfoItem) infoItem; - - itemTitleView.setText(item.getName()); - itemTitleView.setSelected(true); - - final String detailLine = getDetailLine(item); - if (detailLine == null) { - itemAdditionalDetailView.setVisibility(View.GONE); - } else { - itemAdditionalDetailView.setVisibility(View.VISIBLE); - itemAdditionalDetailView.setText(getDetailLine(item)); - } - - CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getThumbnails()); - - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnChannelSelectedListener() != null) { - itemBuilder.getOnChannelSelectedListener().selected(item); - } - }); - - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnChannelSelectedListener() != null) { - itemBuilder.getOnChannelSelectedListener().held(item); - } - return true; - }); - - if (itemChannelDescriptionView != null) { - // itemChannelDescriptionView will be null in the mini variant - if (Utils.isBlank(item.getDescription())) { - itemChannelDescriptionView.setVisibility(View.GONE); - } else { - itemChannelDescriptionView.setVisibility(View.VISIBLE); - itemChannelDescriptionView.setText(item.getDescription()); - // setMaxLines utilize the line space for description if the additional details - // (sub / video count) are not present. - // Case1: 2 lines of description + 1 line additional details - // Case2: 3 lines of description (additionalDetails is GONE) - itemChannelDescriptionView.setMaxLines(getDescriptionMaxLineCount(detailLine)); - } - } - } - - /** - * Returns max number of allowed lines for the description field. - * @param content additional detail content (video / sub count) - * @return max line count - */ - protected int getDescriptionMaxLineCount(@Nullable final String content) { - return content == null ? 3 : 2; - } - - @Nullable - private String getDetailLine(final ChannelInfoItem item) { - if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) { - return Localization.concatenateStrings( - Localization.shortSubscriberCount(itemBuilder.getContext(), - item.getSubscriberCount()), - Localization.localizeStreamCount(itemBuilder.getContext(), - item.getStreamCount())); - } else if (item.getStreamCount() >= 0) { - return Localization.localizeStreamCount(itemBuilder.getContext(), - item.getStreamCount()); - } else if (item.getSubscriberCount() >= 0) { - return Localization.shortSubscriberCount(itemBuilder.getContext(), - item.getSubscriberCount()); - } else { - return null; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java deleted file mode 100644 index 5dee128eb..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ /dev/null @@ -1,210 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; - -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.fragment.app.FragmentActivity; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.text.TextEllipsizer; - -public class CommentInfoItemHolder extends InfoItemHolder { - - private static final int COMMENT_DEFAULT_LINES = 2; - private final int commentHorizontalPadding; - private final int commentVerticalPadding; - - private final RelativeLayout itemRoot; - private final ImageView itemThumbnailView; - private final TextView itemContentView; - private final ImageView itemThumbsUpView; - private final TextView itemLikesCountView; - private final TextView itemTitleView; - private final ImageView itemHeartView; - private final ImageView itemPinnedView; - private final Button repliesButton; - - @NonNull - private final TextEllipsizer textEllipsizer; - - public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_comment_item, parent); - - itemRoot = itemView.findViewById(R.id.itemRoot); - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemContentView = itemView.findViewById(R.id.itemCommentContentView); - itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view); - itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemHeartView = itemView.findViewById(R.id.detail_heart_image_view); - itemPinnedView = itemView.findViewById(R.id.detail_pinned_view); - repliesButton = itemView.findViewById(R.id.replies_button); - - commentHorizontalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_horizontal_padding); - commentVerticalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_vertical_padding); - - textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null); - textEllipsizer.setStateChangeListener(isEllipsized -> { - if (Boolean.TRUE.equals(isEllipsized)) { - denyLinkFocus(); - } else { - determineMovementMethod(); - } - }); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof CommentsInfoItem item)) { - return; - } - - // load the author avatar - CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars()); - if (ImageStrategy.shouldLoadImages()) { - itemThumbnailView.setVisibility(View.VISIBLE); - itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, - commentVerticalPadding, commentVerticalPadding); - } else { - itemThumbnailView.setVisibility(View.GONE); - itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, - commentHorizontalPadding, commentVerticalPadding); - } - itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); - - // setup the top row, with pinned icon, author name and comment date - itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - final String uploaderName = Localization.localizeUserName(item.getUploaderName()); - itemTitleView.setText(Localization.concatenateStrings( - uploaderName, - Localization.relativeTimeOrTextual( - itemBuilder.getContext(), - item.getUploadDate(), - item.getTextualUploadDate()))); - - // setup bottom row, with likes, heart and replies button - itemLikesCountView.setText( - Localization.likeCount(itemBuilder.getContext(), item.getLikeCount())); - - itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - - final boolean hasReplies = item.getReplies() != null; - repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null); - repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE); - repliesButton.setText(hasReplies - ? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : ""); - ((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin = - hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext()); - - - // setup comment content and click listeners to expand/ellipsize it - textEllipsizer.setStreamingService(getServiceById(item.getServiceId())); - textEllipsizer.setStreamUrl(item.getUrl()); - textEllipsizer.setContent(item.getCommentText()); - textEllipsizer.ellipsize(); - - //noinspection ClickableViewAccessibility - itemContentView.setOnTouchListener((v, event) -> { - final CharSequence text = itemContentView.getText(); - if (text instanceof Spanned buffer) { - final int action = event.getAction(); - - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { - final int offset = getOffsetForHorizontalLine(itemContentView, event); - final var links = buffer.getSpans(offset, offset, ClickableSpan.class); - - if (links.length != 0) { - if (action == MotionEvent.ACTION_UP) { - links[0].onClick(itemContentView); - } - // we handle events that intersect links, so return true - return true; - } - } - } - return false; - }); - - itemView.setOnClickListener(view -> { - textEllipsizer.toggle(); - if (itemBuilder.getOnCommentsSelectedListener() != null) { - itemBuilder.getOnCommentsSelectedListener().selected(item); - } - }); - - itemView.setOnLongClickListener(view -> { - if (DeviceUtils.isTv(itemBuilder.getContext())) { - openCommentAuthor(item); - } else { - final CharSequence text = itemContentView.getText(); - if (text != null) { - ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString()); - } - } - return true; - }); - } - - private void openCommentAuthor(@NonNull final CommentsInfoItem item) { - NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(), - item); - } - - private void openCommentReplies(@NonNull final CommentsInfoItem item) { - NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(), - item); - } - - private void allowLinkFocus() { - itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); - } - - private void denyLinkFocus() { - itemContentView.setMovementMethod(null); - } - - private boolean shouldFocusLinks() { - if (itemView.isInTouchMode()) { - return false; - } - - final URLSpan[] urls = itemContentView.getUrls(); - - return urls != null && urls.length != 0; - } - - private void determineMovementMethod() { - if (shouldFocusLinks()) { - allowLinkFocus(); - } else { - denyLinkFocus(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java deleted file mode 100644 index 9e1561786..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -/* - * Created by Christian Schabesberger on 12.02.17. - * - * Copyright (C) Christian Schabesberger 2016 - * InfoItemHolder.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public abstract class InfoItemHolder extends RecyclerView.ViewHolder { - protected final InfoItemBuilder itemBuilder; - - public InfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(LayoutInflater.from(infoItemBuilder.getContext()).inflate(layoutId, parent, false)); - this.itemBuilder = infoItemBuilder; - } - - public abstract void updateFromItem(InfoItem infoItem, - HistoryRecordManager historyRecordManager); - - public void updateState(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java deleted file mode 100644 index f1682b4e4..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -/** - * Playlist card layout. - */ -public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder { - - public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java deleted file mode 100644 index 1cb69208b..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -public class PlaylistGridInfoItemHolder extends PlaylistMiniInfoItemHolder { - public PlaylistGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java deleted file mode 100644 index 7691a377d..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -public class PlaylistInfoItemHolder extends PlaylistMiniInfoItemHolder { - public PlaylistInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java deleted file mode 100644 index b7949318d..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.CoilHelper; - -public class PlaylistMiniInfoItemHolder extends InfoItemHolder { - public final ImageView itemThumbnailView; - private final TextView itemStreamCountView; - public final TextView itemTitleView; - public final TextView itemUploaderView; - - public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); - itemUploaderView = itemView.findViewById(R.id.itemUploaderView); - } - - public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof PlaylistInfoItem)) { - return; - } - final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; - - itemTitleView.setText(item.getName()); - itemStreamCountView.setText(Localization - .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); - itemUploaderView.setText(item.getUploaderName()); - - CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnails()); - - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnPlaylistSelectedListener() != null) { - itemBuilder.getOnPlaylistSelectedListener().selected(item); - } - }); - - itemView.setLongClickable(true); - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnPlaylistSelectedListener() != null) { - itemBuilder.getOnPlaylistSelectedListener().held(item); - } - return true; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java deleted file mode 100644 index 807bad6e0..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -/** - * Card layout for stream. - */ -public class StreamCardInfoItemHolder extends StreamInfoItemHolder { - - public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java deleted file mode 100644 index 8e4a1914e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; - -public class StreamGridInfoItemHolder extends StreamInfoItemHolder { - public StreamGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java deleted file mode 100644 index 80f62eed3..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.text.TextUtils; -import android.view.ViewGroup; -import android.widget.TextView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; - -/* - * Created by Christian Schabesberger on 01.08.16. - *

- * Copyright (C) Christian Schabesberger 2016 - * StreamInfoItemHolder.java is part of NewPipe. - *

- *

- * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- *

- * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - *

- */ - -public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { - public final TextView itemAdditionalDetails; - - public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_stream_item, parent); - } - - public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - super.updateFromItem(infoItem, historyRecordManager); - - if (!(infoItem instanceof StreamInfoItem)) { - return; - } - final StreamInfoItem item = (StreamInfoItem) infoItem; - - itemAdditionalDetails.setText(getStreamInfoDetailLine(item)); - } - - private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { - String viewsAndDate = ""; - if (infoItem.getViewCount() >= 0) { - if (infoItem.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - viewsAndDate = Localization - .listeningCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else if (infoItem.getStreamType().equals(StreamType.LIVE_STREAM)) { - viewsAndDate = Localization - .shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else { - viewsAndDate = Localization - .shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); - } - } - - final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), - infoItem.getUploadDate(), - infoItem.getTextualUploadDate()); - if (!TextUtils.isEmpty(uploadDate)) { - if (viewsAndDate.isEmpty()) { - return uploadDate; - } - - return Localization.concatenateStrings(viewsAndDate, uploadDate); - } - - return viewsAndDate; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java deleted file mode 100644 index 32fa8bf60..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ /dev/null @@ -1,155 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.DependentPreferenceHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.views.AnimatedProgressBar; - -import java.util.concurrent.TimeUnit; - -public class StreamMiniInfoItemHolder extends InfoItemHolder { - public final ImageView itemThumbnailView; - public final TextView itemVideoTitleView; - public final TextView itemUploaderView; - public final TextView itemDurationView; - private final AnimatedProgressBar itemProgressView; - - StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); - itemUploaderView = itemView.findViewById(R.id.itemUploaderView); - itemDurationView = itemView.findViewById(R.id.itemDurationView); - itemProgressView = itemView.findViewById(R.id.itemProgressView); - } - - public StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_stream_mini_item, parent); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof StreamInfoItem)) { - return; - } - final StreamInfoItem item = (StreamInfoItem) infoItem; - - itemVideoTitleView.setText(item.getName()); - itemUploaderView.setText(item.getUploaderName()); - - if (item.getDuration() > 0) { - itemDurationView.setText(Localization.getDurationString(item.getDuration())); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), - R.color.duration_background_color)); - itemDurationView.setVisibility(View.VISIBLE); - - StreamStateEntity state2 = null; - if (DependentPreferenceHelper - .getPositionsInListsEnabled(itemProgressView.getContext())) { - state2 = historyRecordManager.loadStreamState(infoItem) - .blockingGet()[0]; - } - if (state2 != null) { - itemProgressView.setVisibility(View.VISIBLE); - itemProgressView.setMax((int) item.getDuration()); - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state2.getProgressMillis())); - } else { - itemProgressView.setVisibility(View.GONE); - } - } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) { - itemDurationView.setText(R.string.duration_live); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), - R.color.live_duration_background_color)); - itemDurationView.setVisibility(View.VISIBLE); - itemProgressView.setVisibility(View.GONE); - } else { - itemDurationView.setVisibility(View.GONE); - itemProgressView.setVisibility(View.GONE); - } - - // Default thumbnail is shown on error, while loading and if the url is empty - CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getThumbnails()); - - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().selected(item); - } - }); - - switch (item.getStreamType()) { - case AUDIO_STREAM: - case VIDEO_STREAM: - case LIVE_STREAM: - case AUDIO_LIVE_STREAM: - case POST_LIVE_STREAM: - case POST_LIVE_AUDIO_STREAM: - enableLongClick(item); - break; - case NONE: - default: - disableLongClick(); - break; - } - } - - @Override - public void updateState(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - final StreamInfoItem item = (StreamInfoItem) infoItem; - - StreamStateEntity state = null; - if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { - state = historyRecordManager - .loadStreamState(infoItem) - .blockingGet()[0]; - } - if (state != null && item.getDuration() > 0 - && !StreamTypeUtil.isLiveStream(item.getStreamType())) { - itemProgressView.setMax((int) item.getDuration()); - if (itemProgressView.getVisibility() == View.VISIBLE) { - itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressMillis())); - } else { - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressMillis())); - ViewUtils.animate(itemProgressView, true, 500); - } - } else if (itemProgressView.getVisibility() == View.VISIBLE) { - ViewUtils.animate(itemProgressView, false, 500); - } - } - - private void enableLongClick(final StreamInfoItem item) { - itemView.setLongClickable(true); - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().held(item); - } - return true; - }); - } - - private void disableLongClick() { - itemView.setLongClickable(false); - itemView.setOnLongClickListener(null); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt deleted file mode 100644 index 140351b0d..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.ktx - -import android.graphics.Bitmap -import android.graphics.Rect -import androidx.core.graphics.BitmapCompat - -@Suppress("NOTHING_TO_INLINE") -inline fun Bitmap.scale( - width: Int, - height: Int, - srcRect: Rect? = null, - scaleInLinearSpace: Boolean = true -) = BitmapCompat.createScaledBitmap(this, width, height, srcRect, scaleInLinearSpace) diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt deleted file mode 100644 index e32376960..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.ktx - -import android.os.Bundle -import android.os.Parcelable -import androidx.core.os.BundleCompat - -inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { - return BundleCompat.getParcelableArrayList(this, key, T::class.java) -} - -fun Bundle?.toDebugString(): String { - if (this == null) { - return "null" - } - val string = StringBuilder("Bundle{") - for (key in this.keySet()) { - @Suppress("DEPRECATION") // we want this[key] to return items of any type - string.append(" ").append(key).append(" => ").append(this[key]).append(";") - } - string.append(" }") - return string.toString() -} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt b/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt deleted file mode 100644 index ff406fc91..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.ktx - -import android.content.SharedPreferences - -fun SharedPreferences.getStringSafe(key: String, defValue: String): String { - return getString(key, null) ?: defValue -} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt b/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt deleted file mode 100644 index c70af1e7d..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt +++ /dev/null @@ -1,38 +0,0 @@ -@file:JvmName("TextViewUtils") - -package org.schabi.newpipe.ktx - -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator -import android.util.Log -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.core.animation.addListener -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import org.schabi.newpipe.MainActivity - -private const val TAG = "TextViewUtils" - -/** - * Animate the text color of any view that extends [TextView] (Buttons, EditText...). - * - * @param duration the duration of the animation - * @param colorStart the text color to start with - * @param colorEnd the text color to end with - */ -fun TextView.animateTextColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) { - if (MainActivity.DEBUG) { - Log.d( - TAG, - "animateTextColor() called with: " + - "view = [" + this + "], duration = [" + duration + "], " + - "colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]" - ) - } - val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd) - viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() - viewPropertyAnimator.duration = duration - viewPropertyAnimator.addUpdateListener { setTextColor(it.animatedValue as Int) } - viewPropertyAnimator.addListener(onCancel = { setTextColor(colorEnd) }, onEnd = { setTextColor(colorEnd) }) - viewPropertyAnimator.start() -} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt b/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt deleted file mode 100644 index 63f1b2ab5..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt +++ /dev/null @@ -1,73 +0,0 @@ -@file:JvmName("ExceptionUtils") - -package org.schabi.newpipe.ktx - -import java.io.IOException -import java.io.InterruptedIOException - -/** - * @return if throwable is related to Interrupted exceptions, or one of its causes is. - */ -val Throwable.isInterruptedCaused: Boolean - get() = hasExactCause(InterruptedIOException::class.java, InterruptedException::class.java) - -/** - * @return if throwable is related to network issues, or one of its causes is. - */ -val Throwable.isNetworkRelated: Boolean - get() = hasAssignableCause() - -/** - * Calls [hasCause] with the `checkSubtypes` parameter set to false. - */ -fun Throwable.hasExactCause(vararg causesToCheck: Class<*>) = hasCause(false, *causesToCheck) - -/** - * Calls [hasCause] with a reified [Throwable] type. - */ -inline fun Throwable.hasExactCause() = hasExactCause(T::class.java) - -/** - * Calls [hasCause] with the `checkSubtypes` parameter set to true. - */ -fun Throwable?.hasAssignableCause(vararg causesToCheck: Class<*>) = hasCause(true, *causesToCheck) - -/** - * Calls [hasCause] with a reified [Throwable] type. - */ -inline fun Throwable?.hasAssignableCause() = hasAssignableCause(T::class.java) - -/** - * Check if the throwable has some cause from the causes to check, or is itself in it. - * - * If `checkIfAssignable` is true, not only the exact type will be considered equals, but also its subtypes. - * - * @param checkSubtypes if subtypes are also checked. - * @param causesToCheck an array of causes to check. - * - * @see Class.isAssignableFrom - */ -tailrec fun Throwable?.hasCause(checkSubtypes: Boolean, vararg causesToCheck: Class<*>): Boolean { - if (this == null) { - return false - } - - // Check if throwable is a subtype of any of the causes to check - causesToCheck.forEach { causeClass -> - if (checkSubtypes) { - if (causeClass.isAssignableFrom(this.javaClass)) { - return true - } - } else if (causeClass == this.javaClass) { - return true - } - } - - val currentCause: Throwable? = cause - // Check if cause is not pointing to the same instance, to avoid infinite loops. - if (this !== currentCause) { - return currentCause.hasCause(checkSubtypes, *causesToCheck) - } - - return false -} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/View.kt b/app/src/main/java/org/schabi/newpipe/ktx/View.kt deleted file mode 100644 index 432b974cb..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt +++ /dev/null @@ -1,301 +0,0 @@ -@file:JvmName("ViewUtils") - -package org.schabi.newpipe.ktx - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator -import android.content.res.ColorStateList -import android.util.Log -import android.view.View -import androidx.annotation.ColorInt -import androidx.annotation.FloatRange -import androidx.core.animation.addListener -import androidx.core.view.ViewCompat -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.interpolator.view.animation.FastOutSlowInInterpolator - -// logs in this class are disabled by default since it's usually not useful, -// you can enable them by setting this flag to MainActivity.DEBUG -private const val DEBUG = false -private const val TAG = "ViewUtils" - -/** - * Animate the view. - * - * @param enterOrExit true to enter, false to exit - * @param duration how long the animation will take, in milliseconds - * @param animationType Type of the animation - * @param delay how long the animation will wait to start, in milliseconds - * @param execOnEnd runnable that will be executed when the animation ends - */ -@JvmOverloads -fun View.animate( - enterOrExit: Boolean, - duration: Long, - animationType: AnimationType = AnimationType.ALPHA, - delay: Long = 0, - execOnEnd: Runnable? = null -) { - if (DEBUG) { - val id = runCatching { resources.getResourceEntryName(id) }.getOrDefault(id.toString()) - val msg = String.format( - "%8s → [%s:%s] [%s %s:%s] execOnEnd=%s", - enterOrExit, - javaClass.simpleName, - id, - animationType, - duration, - delay, - execOnEnd - ) - Log.d(TAG, "animate(): $msg") - } - if (isVisible && enterOrExit) { - if (DEBUG) { - Log.d(TAG, "animate(): view was already visible > view = [$this]") - } - animate().setListener(null).cancel() - isVisible = true - alpha = 1f - execOnEnd?.run() - return - } else if ((isGone || isInvisible) && !enterOrExit) { - if (DEBUG) { - Log.d(TAG, "animate(): view was already gone > view = [$this]") - } - animate().setListener(null).cancel() - isGone = true - alpha = 0f - execOnEnd?.run() - return - } - animate().setListener(null).cancel() - isVisible = true - - when (animationType) { - AnimationType.ALPHA -> animateAlpha(enterOrExit, duration, delay, execOnEnd) - AnimationType.SCALE_AND_ALPHA -> animateScaleAndAlpha(enterOrExit, duration, delay, execOnEnd) - AnimationType.LIGHT_SCALE_AND_ALPHA -> animateLightScaleAndAlpha(enterOrExit, duration, delay, execOnEnd) - AnimationType.SLIDE_AND_ALPHA -> animateSlideAndAlpha(enterOrExit, duration, delay, execOnEnd) - AnimationType.LIGHT_SLIDE_AND_ALPHA -> animateLightSlideAndAlpha(enterOrExit, duration, delay, execOnEnd) - } -} - -/** - * Animate the background color of a view. - * - * @param duration the duration of the animation - * @param colorStart the background color to start with - * @param colorEnd the background color to end with - */ -fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) { - if (DEBUG) { - Log.d( - TAG, - "animateBackgroundColor() called with: view = [$this], duration = [$duration], " + - "colorStart = [$colorStart], colorEnd = [$colorEnd]" - ) - } - val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd) - viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() - viewPropertyAnimator.duration = duration - - fun listenerAction(color: Int) { - ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color)) - } - viewPropertyAnimator.addUpdateListener { listenerAction(it.animatedValue as Int) } - viewPropertyAnimator.addListener(onCancel = { listenerAction(colorEnd) }, onEnd = { listenerAction(colorEnd) }) - viewPropertyAnimator.start() -} - -fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator { - if (DEBUG) { - Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this") - } - val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat()) - animator.interpolator = FastOutSlowInInterpolator() - animator.duration = duration - - fun listenerAction(value: Int) { - layoutParams.height = value - requestLayout() - } - animator.addUpdateListener { listenerAction((it.animatedValue as Float).toInt()) } - animator.addListener(onCancel = { listenerAction(targetHeight) }, onEnd = { listenerAction(targetHeight) }) - animator.start() - return animator -} - -fun View.animateRotation(duration: Long, targetRotation: Int) { - if (DEBUG) { - Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this") - } - animate().setListener(null).cancel() - animate() - .rotation(targetRotation.toFloat()).setDuration(duration) - .setInterpolator(FastOutSlowInInterpolator()) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationCancel(animation: Animator) { - rotation = targetRotation.toFloat() - } - - override fun onAnimationEnd(animation: Animator) { - rotation = targetRotation.toFloat() - } - }).start() -} - -private fun View.animateAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { - if (enterOrExit) { - animate().setInterpolator(FastOutSlowInInterpolator()).alpha(1f) - .setDuration(duration).setStartDelay(delay) - .setListener(ExecOnEndListener(execOnEnd)) - .start() - } else { - animate().setInterpolator(FastOutSlowInInterpolator()).alpha(0f) - .setDuration(duration).setStartDelay(delay) - .setListener(HideAndExecOnEndListener(this, execOnEnd)) - .start() - } -} - -private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { - if (enterOrExit) { - scaleX = .8f - scaleY = .8f - animate() - .setInterpolator(FastOutSlowInInterpolator()) - .alpha(1f).scaleX(1f).scaleY(1f) - .setDuration(duration).setStartDelay(delay) - .setListener(ExecOnEndListener(execOnEnd)) - .start() - } else { - scaleX = 1f - scaleY = 1f - animate() - .setInterpolator(FastOutSlowInInterpolator()) - .alpha(0f).scaleX(.8f).scaleY(.8f) - .setDuration(duration).setStartDelay(delay) - .setListener(HideAndExecOnEndListener(this, execOnEnd)) - .start() - } -} - -private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { - if (enterOrExit) { - alpha = .5f - scaleX = .95f - scaleY = .95f - animate() - .setInterpolator(FastOutSlowInInterpolator()) - .alpha(1f).scaleX(1f).scaleY(1f) - .setDuration(duration).setStartDelay(delay) - .setListener(ExecOnEndListener(execOnEnd)) - .start() - } else { - alpha = 1f - scaleX = 1f - scaleY = 1f - animate() - .setInterpolator(FastOutSlowInInterpolator()) - .alpha(0f).scaleX(.95f).scaleY(.95f) - .setDuration(duration).setStartDelay(delay) - .setListener(HideAndExecOnEndListener(this, execOnEnd)) - .start() - } -} - -private fun View.animateSlideAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { - if (enterOrExit) { - translationY = -height.toFloat() - alpha = 0f - animate() - .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) - .setDuration(duration).setStartDelay(delay) - .setListener(ExecOnEndListener(execOnEnd)) - .start() - } else { - animate() - .setInterpolator(FastOutSlowInInterpolator()) - .alpha(0f).translationY(-height.toFloat()) - .setDuration(duration).setStartDelay(delay) - .setListener(HideAndExecOnEndListener(this, execOnEnd)) - .start() - } -} - -private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { - if (enterOrExit) { - translationY = -height / 2.0f - alpha = 0f - animate() - .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) - .setDuration(duration).setStartDelay(delay) - .setListener(ExecOnEndListener(execOnEnd)) - .start() - } else { - animate().setInterpolator(FastOutSlowInInterpolator()) - .alpha(0f).translationY(-height / 2.0f) - .setDuration(duration).setStartDelay(delay) - .setListener(HideAndExecOnEndListener(this, execOnEnd)) - .start() - } -} - -@JvmOverloads -fun View.slideUp( - duration: Long, - delay: Long = 0L, - @FloatRange(from = 0.0, to = 1.0) translationPercent: Float = 1.0F, - execOnEnd: Runnable? = null -) { - val newTranslationY = (resources.displayMetrics.heightPixels * translationPercent).toInt() - animate().setListener(null).cancel() - alpha = 0f - translationY = newTranslationY.toFloat() - isVisible = true - animate() - .alpha(1f) - .translationY(0f) - .setStartDelay(delay) - .setDuration(duration) - .setInterpolator(FastOutSlowInInterpolator()) - .setListener(ExecOnEndListener(execOnEnd)) - .start() -} - -/** - * Instead of hiding normally using [animate], which would make - * the recycler view unable to capture touches after being hidden, this just animates the alpha - * value setting it to `0.0` after `200` milliseconds. - */ -fun View.animateHideRecyclerViewAllowingScrolling() { - // not hiding normally because the view needs to still capture touches and allow scroll - animate().alpha(0.0f).setDuration(200).start() -} - -private open class ExecOnEndListener(private val execOnEnd: Runnable?) : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } -} - -private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnable?) : - ExecOnEndListener(execOnEnd) { - override fun onAnimationEnd(animation: Animator) { - view.isGone = true - super.onAnimationEnd(animation) - } -} - -enum class AnimationType { - ALPHA, - SCALE_AND_ALPHA, - LIGHT_SCALE_AND_ALPHA, - SLIDE_AND_ALPHA, - LIGHT_SLIDE_AND_ALPHA -} diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java deleted file mode 100644 index d690a2607..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ /dev/null @@ -1,263 +0,0 @@ -package org.schabi.newpipe.local; - -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewbinding.ViewBinding; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PignateFooterBinding; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.fragments.list.ListViewContract; -import org.schabi.newpipe.info_list.ItemViewMode; - -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode; - -import java.util.function.Supplier; - -/** - * This fragment is design to be used with persistent data such as - * {@link org.schabi.newpipe.database.LocalItem}, and does not cache the data contained - * in the list adapter to avoid extra writes when the it exits or re-enters its lifecycle. - *

- * This fragment destroys its adapter and views when {@link Fragment#onDestroyView()} is - * called and is memory efficient when in backstack. - *

- * - * @param List of {@link org.schabi.newpipe.database.LocalItem}s - * @param {@link Void} - */ -public abstract class BaseLocalListFragment extends BaseStateFragment - implements ListViewContract, SharedPreferences.OnSharedPreferenceChangeListener { - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private static final int LIST_MODE_UPDATE_FLAG = 0x32; - private ViewBinding headerRootBinding; - private ViewBinding footerRootBinding; - protected LocalItemListAdapter itemListAdapter; - protected RecyclerView itemsList; - private int updateFlags = 0; - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - Creation - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - PreferenceManager.getDefaultSharedPreferences(activity) - .registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void onDestroy() { - super.onDestroy(); - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(this); - } - - @Override - public void onResume() { - super.onResume(); - if (updateFlags != 0) { - if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - refreshItemViewMode(); - } - updateFlags = 0; - } - } - - /** - * Updates the item view mode based on user preference. - */ - private void refreshItemViewMode() { - final ItemViewMode itemViewMode = getItemViewMode(requireContext()); - itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) - ? getGridLayoutManager() : getListLayoutManager()); - itemListAdapter.setItemViewMode(itemViewMode); - itemListAdapter.notifyDataSetChanged(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - View - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - protected Supplier getListHeaderSupplier() { - return null; - } - - protected ViewBinding getListFooter() { - return PignateFooterBinding.inflate(activity.getLayoutInflater(), itemsList, false); - } - - protected RecyclerView.LayoutManager getGridLayoutManager() { - final Resources resources = activity.getResources(); - int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); - width += (24 * resources.getDisplayMetrics().density); - final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); - final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); - lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount)); - return lm; - } - - protected RecyclerView.LayoutManager getListLayoutManager() { - return new LinearLayoutManager(activity); - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - itemListAdapter = new LocalItemListAdapter(activity); - - itemsList = rootView.findViewById(R.id.items_list); - refreshItemViewMode(); - - final Supplier listHeaderSupplier = getListHeaderSupplier(); - if (listHeaderSupplier != null) { - itemListAdapter.setHeaderSupplier(listHeaderSupplier); - } - footerRootBinding = getListFooter(); - itemListAdapter.setFooter(footerRootBinding.getRoot()); - - itemsList.setAdapter(itemListAdapter); - } - - @Override - protected void initListeners() { - super.initListeners(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar == null) { - return; - } - - supportActionBar.setDisplayShowTitleEnabled(true); - } - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - Destruction - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onDestroyView() { - super.onDestroyView(); - itemsList = null; - itemListAdapter = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - resetFragment(); - } - - @Override - public void showLoading() { - super.showLoading(); - if (itemsList != null) { - animateHideRecyclerViewAllowingScrolling(itemsList); - } - } - - @Override - public void hideLoading() { - super.hideLoading(); - if (itemsList != null) { - animate(itemsList, true, 200); - } - } - - @Override - public void showEmptyState() { - super.showEmptyState(); - showListFooter(false); - } - - @Deprecated(since = "Calling this method with `true` may cause crashes, see " - + "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115") - @Override - public void showListFooter(final boolean show) { - if (itemsList == null) { - return; - } - itemsList.post(() -> { - if (itemListAdapter != null) { - itemListAdapter.showFooter(show); - } - }); - } - - @Override - public void handleNextItems(final N result) { - isLoading.set(false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - protected void resetFragment() { - if (itemListAdapter != null) { - itemListAdapter.clearStreamItemList(); - } - } - - @Override - public void handleError() { - super.handleError(); - resetFragment(); - - showListFooter(false); - - if (itemsList != null) { - animateHideRecyclerViewAllowingScrolling(itemsList); - } - } - - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (getString(R.string.list_view_mode_key).equals(key)) { - updateFlags |= LIST_MODE_UPDATE_FLAG; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java b/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java deleted file mode 100644 index 5aac75119..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.local; - -import android.view.View; - -import androidx.recyclerview.widget.RecyclerView; - -public class HeaderFooterHolder extends RecyclerView.ViewHolder { - public View view; - - public HeaderFooterHolder(final View v) { - super(v); - view = v; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java deleted file mode 100644 index 041d16d43..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.local; - -import android.content.Context; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.util.OnClickGesture; - -/* - * Created by Christian Schabesberger on 26.09.16. - *

- * Copyright (C) Christian Schabesberger 2016 - * InfoItemBuilder.java is part of NewPipe. - *

- * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class LocalItemBuilder { - private final Context context; - - private OnClickGesture onSelectedListener; - - public LocalItemBuilder(final Context context) { - this.context = context; - } - - public Context getContext() { - return context; - } - - public OnClickGesture getOnItemSelectedListener() { - return onSelectedListener; - } - - public void setOnItemSelectedListener(final OnClickGesture listener) { - this.onSelectedListener = listener; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java deleted file mode 100644 index 6bbe536e3..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ /dev/null @@ -1,420 +0,0 @@ -package org.schabi.newpipe.local; - -import android.content.Context; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; -import org.schabi.newpipe.local.holder.LocalItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder; -import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; -import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; -import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; -import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; -import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; -import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; -import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; -import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; -import org.schabi.newpipe.util.FallbackViewHolder; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.OnClickGesture; - -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Supplier; - -/* - * Created by Christian Schabesberger on 01.08.16. - * - * Copyright (C) Christian Schabesberger 2016 - * InfoListAdapter.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class LocalItemListAdapter extends RecyclerView.Adapter { - private static final String TAG = LocalItemListAdapter.class.getSimpleName(); - private static final boolean DEBUG = false; - - private static final int HEADER_TYPE = 0; - private static final int FOOTER_TYPE = 1; - - private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000; - private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001; - private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002; - private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003; - private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004; - private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005; - - private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; - private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001; - private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002; - private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003; - - private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000; - private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001; - private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002; - private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003; - - private final LocalItemBuilder localItemBuilder; - private final ArrayList localItems; - private final HistoryRecordManager recordManager; - private final DateTimeFormatter dateTimeFormatter; - - private boolean showFooter = false; - private Supplier headerSupplier = null; - private View footer = null; - private ItemViewMode itemViewMode = ItemViewMode.LIST; - private boolean useItemHandle = false; - - public LocalItemListAdapter(final Context context) { - recordManager = new HistoryRecordManager(context); - localItemBuilder = new LocalItemBuilder(context); - localItems = new ArrayList<>(); - - dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) - .withLocale(Localization.getPreferredLocale(context)); - } - - public void setSelectedListener(final OnClickGesture listener) { - localItemBuilder.setOnItemSelectedListener(listener); - } - - public void unsetSelectedListener() { - localItemBuilder.setOnItemSelectedListener(null); - } - - public void addItems(@Nullable final List data) { - if (data == null) { - return; - } - if (DEBUG) { - Log.d(TAG, "addItems() before > localItems.size() = " - + localItems.size() + ", data.size() = " + data.size()); - } - - final int offsetStart = sizeConsideringHeader(); - localItems.addAll(data); - - if (DEBUG) { - Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + ", " - + "localItems.size() = " + localItems.size() + ", " - + "header = " + hasHeader() + ", footer = " + footer + ", " - + "showFooter = " + showFooter); - } - notifyItemRangeInserted(offsetStart, data.size()); - - if (footer != null && showFooter) { - final int footerNow = sizeConsideringHeader(); - notifyItemMoved(offsetStart, footerNow); - - if (DEBUG) { - Log.d(TAG, "addItems() footer from " + offsetStart - + " to " + footerNow); - } - } - } - - public void removeItem(final LocalItem data) { - final int index = localItems.indexOf(data); - if (index != -1) { - localItems.remove(index); - notifyItemRemoved(index + (hasHeader() ? 1 : 0)); - } else { - // this happens when - // 1) removeItem is called on infoItemDuplicate as in showStreamItemDialog of - // LocalPlaylistFragment in this case need to implement delete object by it's duplicate - - // OR - - // 2)data not in itemList and UI is still not updated so notifyDataSetChanged() - notifyDataSetChanged(); - } - } - - public boolean swapItems(final int fromAdapterPosition, final int toAdapterPosition) { - final int actualFrom = adapterOffsetWithoutHeader(fromAdapterPosition); - final int actualTo = adapterOffsetWithoutHeader(toAdapterPosition); - - if (actualFrom < 0 || actualTo < 0) { - return false; - } - if (actualFrom >= localItems.size() || actualTo >= localItems.size()) { - return false; - } - - localItems.add(actualTo, localItems.remove(actualFrom)); - notifyItemMoved(fromAdapterPosition, toAdapterPosition); - return true; - } - - public void clearStreamItemList() { - if (localItems.isEmpty()) { - return; - } - localItems.clear(); - notifyDataSetChanged(); - } - - public void setItemViewMode(final ItemViewMode itemViewMode) { - this.itemViewMode = itemViewMode; - } - - public void setUseItemHandle(final boolean useItemHandle) { - this.useItemHandle = useItemHandle; - } - - public void setHeaderSupplier(@Nullable final Supplier headerSupplier) { - final boolean changed = headerSupplier != this.headerSupplier; - this.headerSupplier = headerSupplier; - if (changed) { - notifyDataSetChanged(); - } - } - - public void setFooter(final View view) { - this.footer = view; - } - - protected boolean hasHeader() { - return this.headerSupplier != null; - } - - @Deprecated(since = "Calling this method with `true` may cause crashes, see " - + "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115") - public void showFooter(final boolean show) { - if (DEBUG) { - Log.d(TAG, "showFooter() called with: show = [" + show + "]"); - } - if (show == showFooter) { - return; - } - - showFooter = show; - if (show) { - Log.w(TAG, "Calling LocalItemListAdapter.showFooter(true) may cause crashes, see https" - + "://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115"); - notifyItemInserted(sizeConsideringHeader()); - } else { - notifyItemRemoved(sizeConsideringHeader()); - } - } - - private int adapterOffsetWithoutHeader(final int offset) { - return offset - (hasHeader() ? 1 : 0); - } - - private int sizeConsideringHeader() { - return localItems.size() + (hasHeader() ? 1 : 0); - } - - public ArrayList getItemsList() { - return localItems; - } - - @Override - public int getItemCount() { - int count = localItems.size(); - if (hasHeader()) { - count++; - } - if (footer != null && showFooter) { - count++; - } - - if (DEBUG) { - Log.d(TAG, "getItemCount() called, count = " + count + ", " - + "localItems.size() = " + localItems.size() + ", " - + "header = " + hasHeader() + ", footer = " + footer + ", " - + "showFooter = " + showFooter); - } - return count; - } - - @SuppressWarnings("FinalParameters") - @Override - public int getItemViewType(int position) { - if (DEBUG) { - Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); - } - - if (hasHeader() && position == 0) { - return HEADER_TYPE; - } else if (hasHeader()) { - position--; - } - if (footer != null && position == localItems.size() && showFooter) { - return FOOTER_TYPE; - } - final LocalItem item = localItems.get(position); - switch (item.getLocalItemType()) { - case PLAYLIST_LOCAL_ITEM: - if (useItemHandle) { - return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.CARD) { - return LOCAL_PLAYLIST_CARD_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.GRID) { - return LOCAL_PLAYLIST_GRID_HOLDER_TYPE; - } else { - return LOCAL_PLAYLIST_HOLDER_TYPE; - } - case PLAYLIST_REMOTE_ITEM: - if (useItemHandle) { - return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.CARD) { - return REMOTE_PLAYLIST_CARD_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.GRID) { - return REMOTE_PLAYLIST_GRID_HOLDER_TYPE; - } else { - return REMOTE_PLAYLIST_HOLDER_TYPE; - } - case PLAYLIST_STREAM_ITEM: - if (itemViewMode == ItemViewMode.CARD) { - return STREAM_PLAYLIST_CARD_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.GRID) { - return STREAM_PLAYLIST_GRID_HOLDER_TYPE; - } else { - return STREAM_PLAYLIST_HOLDER_TYPE; - } - case STATISTIC_STREAM_ITEM: - if (itemViewMode == ItemViewMode.CARD) { - return STREAM_STATISTICS_CARD_HOLDER_TYPE; - } else if (itemViewMode == ItemViewMode.GRID) { - return STREAM_STATISTICS_GRID_HOLDER_TYPE; - } else { - return STREAM_STATISTICS_HOLDER_TYPE; - } - default: - Log.e(TAG, "No holder type has been considered for item: [" - + item.getLocalItemType() + "]"); - return -1; - } - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int type) { - if (DEBUG) { - Log.d(TAG, "onCreateViewHolder() called with: " - + "parent = [" + parent + "], type = [" + type + "]"); - } - switch (type) { - case HEADER_TYPE: - return new HeaderFooterHolder(headerSupplier.get()); - case FOOTER_TYPE: - return new HeaderFooterHolder(footer); - case LOCAL_PLAYLIST_HOLDER_TYPE: - return new LocalPlaylistItemHolder(localItemBuilder, parent); - case LOCAL_PLAYLIST_GRID_HOLDER_TYPE: - return new LocalPlaylistGridItemHolder(localItemBuilder, parent); - case LOCAL_PLAYLIST_CARD_HOLDER_TYPE: - return new LocalPlaylistCardItemHolder(localItemBuilder, parent); - case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE: - return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent); - case REMOTE_PLAYLIST_HOLDER_TYPE: - return new RemotePlaylistItemHolder(localItemBuilder, parent); - case REMOTE_PLAYLIST_GRID_HOLDER_TYPE: - return new RemotePlaylistGridItemHolder(localItemBuilder, parent); - case REMOTE_PLAYLIST_CARD_HOLDER_TYPE: - return new RemotePlaylistCardItemHolder(localItemBuilder, parent); - case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE: - return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent); - case STREAM_PLAYLIST_HOLDER_TYPE: - return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); - case STREAM_PLAYLIST_GRID_HOLDER_TYPE: - return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent); - case STREAM_PLAYLIST_CARD_HOLDER_TYPE: - return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent); - case STREAM_STATISTICS_HOLDER_TYPE: - return new LocalStatisticStreamItemHolder(localItemBuilder, parent); - case STREAM_STATISTICS_GRID_HOLDER_TYPE: - return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent); - case STREAM_STATISTICS_CARD_HOLDER_TYPE: - return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent); - default: - Log.e(TAG, "No view type has been considered for holder: [" + type + "]"); - return new FallbackViewHolder(new View(parent.getContext())); - } - } - - @SuppressWarnings("FinalParameters") - @Override - public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { - if (DEBUG) { - Log.d(TAG, "onBindViewHolder() called with: " - + "holder = [" + holder.getClass().getSimpleName() + "], " - + "position = [" + position + "]"); - } - - if (holder instanceof LocalItemHolder) { - // If header isn't null, offset the items by -1 - if (hasHeader()) { - position--; - } - - ((LocalItemHolder) holder) - .updateFromItem(localItems.get(position), recordManager, dateTimeFormatter); - } else if (holder instanceof HeaderFooterHolder && position == 0 && hasHeader()) { - ((HeaderFooterHolder) holder).view = headerSupplier.get(); - } else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader() - && footer != null && showFooter) { - ((HeaderFooterHolder) holder).view = footer; - } - } - - @Override - public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position, - @NonNull final List payloads) { - if (!payloads.isEmpty() && holder instanceof LocalItemHolder) { - for (final Object payload : payloads) { - if (payload instanceof StreamStateEntity) { - ((LocalItemHolder) holder).updateState(localItems - .get(hasHeader() ? position - 1 : position), recordManager); - } else if (payload instanceof Boolean) { - ((LocalItemHolder) holder).updateState(localItems - .get(hasHeader() ? position - 1 : position), recordManager); - } - } - } else { - onBindViewHolder(holder, position); - } - } - - public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { - return new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(final int position) { - final int type = getItemViewType(position); - return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; - } - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java deleted file mode 100644 index 1f3772dd5..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ /dev/null @@ -1,559 +0,0 @@ -package org.schabi.newpipe.local.bookmark; - -import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; -import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; - -import android.content.DialogInterface; -import android.os.Bundle; -import android.os.Parcelable; -import android.text.InputType; -import android.util.Log; -import android.util.Pair; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentManager; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import com.evernote.android.state.State; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.databinding.DialogEditTextBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.BaseLocalListFragment; -import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; -import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public final class BookmarkFragment extends BaseLocalListFragment, Void> - implements DebounceSavable { - - private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; - @State - Parcelable itemsListState; - - private Subscription databaseSubscription; - private CompositeDisposable disposables = new CompositeDisposable(); - private LocalPlaylistManager localPlaylistManager; - private RemotePlaylistManager remotePlaylistManager; - private ItemTouchHelper itemTouchHelper; - - /* Have the bookmarked playlists been fully loaded from db */ - private AtomicBoolean isLoadingComplete; - - /* Gives enough time to avoid interrupting user sorting operations */ - @Nullable - private DebounceSaver debounceSaver; - - private List> deletedItems; - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Creation - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (activity == null) { - return; - } - final AppDatabase database = NewPipeDatabase.getInstance(activity); - localPlaylistManager = new LocalPlaylistManager(database); - remotePlaylistManager = new RemotePlaylistManager(database); - disposables = new CompositeDisposable(); - - isLoadingComplete = new AtomicBoolean(); - debounceSaver = new DebounceSaver(3000, this); - - deletedItems = new ArrayList<>(); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - final Bundle savedInstanceState) { - - if (!useAsFrontPage) { - setTitle(activity.getString(R.string.tab_bookmarks)); - } - return inflater.inflate(R.layout.fragment_bookmarks, container, false); - } - - @Override - public void onResume() { - super.onResume(); - if (activity != null) { - setTitle(activity.getString(R.string.tab_bookmarks)); - } - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Views - /////////////////////////////////////////////////////////////////////////// - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - itemListAdapter.setUseItemHandle(true); - } - - @Override - protected void initListeners() { - super.initListeners(); - - itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(itemsList); - - itemListAdapter.setSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final LocalItem selectedItem) { - final FragmentManager fragmentManager = getFM(); - - if (selectedItem instanceof PlaylistMetadataEntry) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), - entry.getOrderingName()); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - NavigationHelper.openPlaylistFragment( - fragmentManager, - entry.getServiceId(), - entry.getUrl(), - entry.getOrderingName()); - } - } - - @Override - public void held(final LocalItem selectedItem) { - if (selectedItem instanceof PlaylistMetadataEntry) { - showLocalDialog((PlaylistMetadataEntry) selectedItem); - } else if (selectedItem instanceof PlaylistRemoteEntity) { - showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); - } - } - - @Override - public void drag(final LocalItem selectedItem, - final RecyclerView.ViewHolder viewHolder) { - if (itemTouchHelper != null) { - itemTouchHelper.startDrag(viewHolder); - } - } - }); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Loading - /////////////////////////////////////////////////////////////////////////// - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - if (debounceSaver != null) { - disposables.add(debounceSaver.getDebouncedSaver()); - debounceSaver.setNoChangesToSave(); - } - isLoadingComplete.set(false); - - getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getPlaylistsSubscriber()); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Destruction - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onPause() { - super.onPause(); - itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); - - // Save on exit - saveImmediate(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - if (disposables != null) { - disposables.clear(); - } - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - - databaseSubscription = null; - itemTouchHelper = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (debounceSaver != null) { - debounceSaver.getDebouncedSaveSignal().onComplete(); - } - if (disposables != null) { - disposables.dispose(); - } - - debounceSaver = null; - disposables = null; - localPlaylistManager = null; - remotePlaylistManager = null; - itemsListState = null; - - isLoadingComplete = null; - deletedItems = null; - } - - /////////////////////////////////////////////////////////////////////////// - // Subscriptions Loader - /////////////////////////////////////////////////////////////////////////// - - private Subscriber> getPlaylistsSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - showLoading(); - isLoadingComplete.set(false); - - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - databaseSubscription = s; - databaseSubscription.request(1); - } - - @Override - public void onNext(final List subscriptions) { - if (debounceSaver == null || !debounceSaver.getIsModified()) { - handleResult(subscriptions); - isLoadingComplete.set(true); - } - if (databaseSubscription != null) { - databaseSubscription.request(1); - } - } - - @Override - public void onError(final Throwable exception) { - showError(new ErrorInfo(exception, - UserAction.REQUESTED_BOOKMARK, "Loading playlists")); - } - - @Override - public void onComplete() { - } - }; - } - - @Override - public void handleResult(@NonNull final List result) { - super.handleResult(result); - - itemListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - showEmptyState(); - return; - } - - itemListAdapter.addItems(result); - if (itemsListState != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - hideLoading(); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - /////////////////////////////////////////////////////////////////////////// - - @Override - protected void resetFragment() { - super.resetFragment(); - if (disposables != null) { - disposables.clear(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist Metadata Manipulation - //////////////////////////////////////////////////////////////////////////*/ - - private void changeLocalPlaylistName(final long id, final String name) { - if (localPlaylistManager == null) { - return; - } - - if (DEBUG) { - Log.d(TAG, "Updating playlist id=[" + id + "] " - + "with new name=[" + name + "] items"); - } - - final Disposable disposable = localPlaylistManager.renamePlaylist(id, name) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError( - new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, - "Changing playlist name"))); - disposables.add(disposable); - } - - private void deleteItem(final PlaylistLocalItem item) { - if (itemListAdapter == null) { - return; - } - itemListAdapter.removeItem(item); - - if (item instanceof PlaylistMetadataEntry) { - deletedItems.add(new Pair<>(item.getUid(), - LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)); - } else if (item instanceof PlaylistRemoteEntity) { - deletedItems.add(new Pair<>(item.getUid(), - LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)); - } - - if (debounceSaver != null) { - debounceSaver.setHasChangesToSave(); - saveImmediate(); - } - } - - @Override - public void saveImmediate() { - if (itemListAdapter == null) { - return; - } - - // List must be loaded and modified in order to save - if (isLoadingComplete == null || debounceSaver == null - || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { - return; - } - - final List items = itemListAdapter.getItemsList(); - final List localItemsUpdate = new ArrayList<>(); - final List localItemsDeleteUid = new ArrayList<>(); - final List remoteItemsUpdate = new ArrayList<>(); - final List remoteItemsDeleteUid = new ArrayList<>(); - - // Calculate display index - for (int i = 0; i < items.size(); i++) { - final LocalItem item = items.get(i); - - if (item instanceof PlaylistMetadataEntry - && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) { - ((PlaylistMetadataEntry) item).setDisplayIndex((long) i); - localItemsUpdate.add((PlaylistMetadataEntry) item); - } else if (item instanceof PlaylistRemoteEntity - && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) { - ((PlaylistRemoteEntity) item).setDisplayIndex((long) i); - remoteItemsUpdate.add((PlaylistRemoteEntity) item); - } - } - - // Find deleted items - for (final Pair item : deletedItems) { - if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) { - localItemsDeleteUid.add(item.first); - } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) { - remoteItemsDeleteUid.add(item.first); - } - } - - deletedItems.clear(); - - // 1. Update local playlists - // 2. Update remote playlists - // 3. Set NoChangesToSave - disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid) - .mergeWith(remotePlaylistManager.updatePlaylists( - remoteItemsUpdate, remoteItemsDeleteUid)) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - if (debounceSaver != null) { - debounceSaver.setNoChangesToSave(); - } - }, - throwable -> showError(new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, "Saving playlist")) - )); - - } - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; - if (shouldUseGridLayout(requireContext())) { - directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; - } - return new ItemTouchHelper.SimpleCallback(directions, ItemTouchHelper.ACTION_STATE_IDLE) { - @Override - public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, - final int viewSize, - final int viewSizeOutOfBounds, - final int totalSize, - final long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, - viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, - Math.abs(standardSpeed)); - return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder source, - @NonNull final RecyclerView.ViewHolder target) { - - // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder. - if (itemListAdapter == null - || source.getItemViewType() != target.getItemViewType() - && !( - ( - (source instanceof LocalBookmarkPlaylistItemHolder) - || (source instanceof RemoteBookmarkPlaylistItemHolder) - ) - && ( - (target instanceof LocalBookmarkPlaylistItemHolder) - || (target instanceof RemoteBookmarkPlaylistItemHolder) - )) - ) { - return false; - } - - final int sourceIndex = source.getBindingAdapterPosition(); - final int targetIndex = target.getBindingAdapterPosition(); - final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); - if (isSwapped && debounceSaver != null) { - debounceSaver.setHasChangesToSave(); - } - return isSwapped; - } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, - final int swipeDir) { - // Do nothing. - } - }; - } - - /////////////////////////////////////////////////////////////////////////// - // Utils - /////////////////////////////////////////////////////////////////////////// - - private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { - showDeleteDialog(item.getOrderingName(), item); - } - - private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { - final String rename = getString(R.string.rename); - final String delete = getString(R.string.delete); - final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); - final boolean isThumbnailPermanent = localPlaylistManager - .getIsPlaylistThumbnailPermanent(selectedItem.getUid()); - - final ArrayList items = new ArrayList<>(); - items.add(rename); - items.add(delete); - if (isThumbnailPermanent) { - items.add(unsetThumbnail); - } - - final DialogInterface.OnClickListener action = (d, index) -> { - if (items.get(index).equals(rename)) { - showRenameDialog(selectedItem); - } else if (items.get(index).equals(delete)) { - showDeleteDialog(selectedItem.getOrderingName(), selectedItem); - } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { - final long thumbnailStreamId = localPlaylistManager - .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); - localPlaylistManager - .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - } - }; - - new AlertDialog.Builder(activity) - .setItems(items.toArray(new String[0]), action) - .show(); - } - - private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { - final DialogEditTextBinding dialogBinding = - DialogEditTextBinding.inflate(getLayoutInflater()); - dialogBinding.dialogEditText.setHint(R.string.name); - dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - dialogBinding.dialogEditText.setText(selectedItem.getOrderingName()); - - new AlertDialog.Builder(activity) - .setView(dialogBinding.getRoot()) - .setPositiveButton(R.string.rename_playlist, (dialog, which) -> - changeLocalPlaylistName( - selectedItem.getUid(), - dialogBinding.dialogEditText.getText().toString())) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void showDeleteDialog(final String name, final PlaylistLocalItem item) { - if (activity == null || disposables == null) { - return; - } - - new AlertDialog.Builder(activity) - .setTitle(name) - .setMessage(R.string.delete_playlist_prompt) - .setCancelable(true) - .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item)) - .setNegativeButton(R.string.cancel, null) - .show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java deleted file mode 100644 index 25eb2f652..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.schabi.newpipe.local.bookmark; - -import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.local.playlist.RemotePlaylistManager; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -/** - * Takes care of remote and local playlists at once, hence "merged". - */ -public final class MergedPlaylistManager { - - private MergedPlaylistManager() { - } - - public static Flowable> getMergedOrderedPlaylists( - final LocalPlaylistManager localPlaylistManager, - final RemotePlaylistManager remotePlaylistManager) { - return Flowable.combineLatest( - localPlaylistManager.getPlaylists(), - remotePlaylistManager.getPlaylists(), - MergedPlaylistManager::merge - ); - } - - /** - * Merge localPlaylists and remotePlaylists by the display index. - * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}. - * - * @param localPlaylists local playlists, already sorted by display index - * @param remotePlaylists remote playlists, already sorted by display index - * @return merged playlists - */ - public static List merge( - final List localPlaylists, - final List remotePlaylists) { - - // This algorithm is similar to the merge operation in merge sort. - final List result = new ArrayList<>( - localPlaylists.size() + remotePlaylists.size()); - final List itemsWithSameIndex = new ArrayList<>(); - - int i = 0; - int j = 0; - while (i < localPlaylists.size()) { - while (j < remotePlaylists.size()) { - if (remotePlaylists.get(j).getDisplayIndex() - <= localPlaylists.get(i).getDisplayIndex()) { - addItem(result, remotePlaylists.get(j), itemsWithSameIndex); - j++; - } else { - break; - } - } - addItem(result, localPlaylists.get(i), itemsWithSameIndex); - i++; - } - while (j < remotePlaylists.size()) { - addItem(result, remotePlaylists.get(j), itemsWithSameIndex); - j++; - } - addItemsWithSameIndex(result, itemsWithSameIndex); - - return result; - } - - private static void addItem(final List result, - final PlaylistLocalItem item, - final List itemsWithSameIndex) { - if (!itemsWithSameIndex.isEmpty() - && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) { - // The new item has a different display index, add previous items with same - // index to the result. - addItemsWithSameIndex(result, itemsWithSameIndex); - itemsWithSameIndex.clear(); - } - itemsWithSameIndex.add(item); - } - - private static void addItemsWithSameIndex(final List result, - final List itemsWithSameIndex) { - Collections.sort(itemsWithSameIndex, - Comparator.comparing(PlaylistLocalItem::getOrderingName, - Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))); - result.addAll(itemsWithSameIndex); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java deleted file mode 100644 index 48d17d1d8..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.schabi.newpipe.local.dialog; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL_ID; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.local.LocalItemListAdapter; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public final class PlaylistAppendDialog extends PlaylistDialog { - private static final String TAG = PlaylistAppendDialog.class.getCanonicalName(); - - private RecyclerView playlistRecyclerView; - private LocalItemListAdapter playlistAdapter; - private TextView playlistDuplicateIndicator; - - private final CompositeDisposable playlistDisposables = new CompositeDisposable(); - - /** - * Create a new instance of {@link PlaylistAppendDialog}. - * - * @param streamEntities a list of {@link StreamEntity} to be added to playlists - * @return a new instance of {@link PlaylistAppendDialog} - */ - public static PlaylistAppendDialog newInstance(final List streamEntities) { - final PlaylistAppendDialog dialog = new PlaylistAppendDialog(); - dialog.setStreamEntities(streamEntities); - return dialog; - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - Creation - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - return inflater.inflate(R.layout.dialog_playlists, container); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - final LocalPlaylistManager playlistManager = - new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); - - playlistAdapter = new LocalItemListAdapter(getActivity()); - playlistAdapter.setSelectedListener(selectedItem -> { - final List entities = getStreamEntities(); - if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) { - onPlaylistSelected(playlistManager, - (PlaylistDuplicatesEntry) selectedItem, entities); - } - }); - - playlistRecyclerView = view.findViewById(R.id.playlist_list); - playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); - playlistRecyclerView.setAdapter(playlistAdapter); - - playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate); - - final View newPlaylistButton = view.findViewById(R.id.newPlaylist); - newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog()); - - playlistDisposables.add(playlistManager - .getPlaylistDuplicates(getStreamEntities().get(0).getUrl()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onPlaylistsReceived)); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - Destruction - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onDestroyView() { - super.onDestroyView(); - playlistDisposables.dispose(); - if (playlistAdapter != null) { - playlistAdapter.unsetSelectedListener(); - } - - playlistDisposables.clear(); - playlistRecyclerView = null; - playlistAdapter = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Helper - //////////////////////////////////////////////////////////////////////////*/ - - /** Display create playlist dialog. */ - public void openCreatePlaylistDialog() { - if (getStreamEntities() == null || !isAdded()) { - return; - } - - final PlaylistCreationDialog playlistCreationDialog = - PlaylistCreationDialog.newInstance(getStreamEntities()); - // Move the dismissListener to the new dialog. - playlistCreationDialog.setOnDismissListener(this.getOnDismissListener()); - this.setOnDismissListener(null); - - playlistCreationDialog.show(getParentFragmentManager(), TAG); - requireDialog().dismiss(); - } - - private void onPlaylistsReceived(@NonNull final List playlists) { - if (playlistAdapter != null - && playlistRecyclerView != null - && playlistDuplicateIndicator != null) { - playlistAdapter.clearStreamItemList(); - playlistAdapter.addItems(playlists); - playlistRecyclerView.setVisibility(View.VISIBLE); - playlistDuplicateIndicator.setVisibility( - anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE); - } - } - - private boolean anyPlaylistContainsDuplicates(final List playlists) { - return playlists.stream() - .anyMatch(playlist -> playlist.getTimesStreamIsContained() > 0); - } - - private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, - @NonNull final PlaylistDuplicatesEntry playlist, - @NonNull final List streams) { - - final String toastText; - if (playlist.getTimesStreamIsContained() > 0) { - toastText = getString(R.string.playlist_add_stream_success_duplicate, - playlist.getTimesStreamIsContained()); - } else { - toastText = getString(R.string.playlist_add_stream_success); - } - - final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT); - - playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> { - successToast.show(); - - if (playlist.getThumbnailStreamId() != null - && playlist.getThumbnailStreamId() == DEFAULT_THUMBNAIL_ID - ) { - playlistDisposables.add(manager - .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), - false) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignore -> successToast.show())); - } - })); - - requireDialog().dismiss(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java deleted file mode 100644 index 0d5cfac23..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.schabi.newpipe.local.dialog; - -import android.app.Dialog; -import android.os.Bundle; -import android.text.InputType; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog.Builder; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.DialogEditTextBinding; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; - -public final class PlaylistCreationDialog extends PlaylistDialog { - - /** - * Create a new instance of {@link PlaylistCreationDialog}. - * - * @param streamEntities a list of {@link StreamEntity} to be added to playlists - * @return a new instance of {@link PlaylistCreationDialog} - */ - public static PlaylistCreationDialog newInstance(final List streamEntities) { - final PlaylistCreationDialog dialog = new PlaylistCreationDialog(); - dialog.setStreamEntities(streamEntities); - return dialog; - } - - /*////////////////////////////////////////////////////////////////////////// - // Dialog - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - if (getStreamEntities() == null) { - return super.onCreateDialog(savedInstanceState); - } - - final DialogEditTextBinding dialogBinding = - DialogEditTextBinding.inflate(getLayoutInflater()); - dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext())); - dialogBinding.dialogEditText.setHint(R.string.name); - dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - - final Builder dialogBuilder = new Builder(requireContext(), - ThemeHelper.getDialogTheme(requireContext())) - .setTitle(R.string.create_playlist) - .setView(dialogBinding.getRoot()) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.create, (dialogInterface, i) -> { - final String name = dialogBinding.dialogEditText.getText().toString(); - final LocalPlaylistManager playlistManager = - new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); - final Toast successToast = Toast.makeText(getActivity(), - R.string.playlist_creation_success, - Toast.LENGTH_SHORT); - - playlistManager.createPlaylist(name, getStreamEntities()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(longs -> successToast.show()); - }); - return dialogBuilder.create(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java deleted file mode 100644 index 612c38181..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ /dev/null @@ -1,181 +0,0 @@ -package org.schabi.newpipe.local.dialog; - -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Bundle; -import android.view.Window; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.util.StateSaver; - -import java.util.List; -import java.util.Objects; -import java.util.Queue; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.Disposable; - -public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { - - @Nullable - private DialogInterface.OnDismissListener onDismissListener = null; - - private List streamEntities; - - private org.schabi.newpipe.util.SavedState savedState; - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - savedState = StateSaver.tryToRestore(savedInstanceState, this); - } - - @Override - public void onDestroy() { - super.onDestroy(); - StateSaver.onDestroy(savedState); - } - - public List getStreamEntities() { - return streamEntities; - } - - @NonNull - @Override - public Dialog onCreateDialog(final Bundle savedInstanceState) { - final Dialog dialog = super.onCreateDialog(savedInstanceState); - //remove title - final Window window = dialog.getWindow(); - if (window != null) { - window.requestFeature(Window.FEATURE_NO_TITLE); - } - return dialog; - } - - @Override - public void onDismiss(@NonNull final DialogInterface dialog) { - super.onDismiss(dialog); - if (onDismissListener != null) { - onDismissListener.onDismiss(dialog); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public String generateSuffix() { - final int size = streamEntities == null ? 0 : streamEntities.size(); - return "." + size + ".list"; - } - - @Override - public void writeTo(final Queue objectsToSave) { - objectsToSave.add(streamEntities); - } - - @Override - @SuppressWarnings("unchecked") - public void readFrom(@NonNull final Queue savedObjects) { - streamEntities = (List) savedObjects.poll(); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - if (getActivity() != null) { - savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), - savedState, outState, this); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Getter + Setter - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - public DialogInterface.OnDismissListener getOnDismissListener() { - return onDismissListener; - } - - public void setOnDismissListener( - @Nullable final DialogInterface.OnDismissListener onDismissListener - ) { - this.onDismissListener = onDismissListener; - } - - protected void setStreamEntities(final List streamEntities) { - this.streamEntities = streamEntities; - } - - /*////////////////////////////////////////////////////////////////////////// - // Dialog creation - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Creates a {@link PlaylistAppendDialog} when playlists exists, - * otherwise a {@link PlaylistCreationDialog}. - * - * @param context context used for accessing the database - * @param streamEntities used for crating the dialog - * @param onExec execution that should occur after a dialog got created, e.g. showing it - * @return the disposable that was created - */ - public static Disposable createCorrespondingDialog( - final Context context, - final List streamEntities, - final Consumer onExec) { - - return new LocalPlaylistManager(NewPipeDatabase.getInstance(context)) - .hasPlaylists() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(hasPlaylists -> - onExec.accept(hasPlaylists - ? PlaylistAppendDialog.newInstance(streamEntities) - : PlaylistCreationDialog.newInstance(streamEntities)) - ); - } - - /** - * Creates a {@link PlaylistAppendDialog} when playlists exists, - * otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no - * dialog will be created. - * - * @param player the player from which to extract the context and the play queue - * @param fragmentManager the fragment manager to use to show the dialog - * @return the disposable that was created - */ - public static Disposable showForPlayQueue( - final Player player, - @NonNull final FragmentManager fragmentManager) { - - final List streamEntities = Stream.of(player.getPlayQueue()) - .filter(Objects::nonNull) - .flatMap(playQueue -> playQueue.getStreams().stream()) - .map(StreamEntity::new) - .collect(Collectors.toList()); - if (streamEntities.isEmpty()) { - return Disposable.empty(); - } - - return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities, - dialog -> dialog.show(fragmentManager, "PlaylistDialog")); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt deleted file mode 100644 index 3e3a47f57..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ /dev/null @@ -1,185 +0,0 @@ -package org.schabi.newpipe.local.feed - -import android.content.Context -import android.util.Log -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.schedulers.Schedulers -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.ZoneOffset -import org.schabi.newpipe.MainActivity.DEBUG -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.database.feed.model.FeedEntity -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.stream.StreamWithState -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -class FeedDatabaseManager(context: Context) { - private val database = NewPipeDatabase.getInstance(context) - private val feedTable = database.feedDAO() - private val feedGroupTable = database.feedGroupDAO() - private val streamTable = database.streamDAO() - - companion object { - /** - * Only items that are newer than this will be saved. - */ - val FEED_OLDEST_ALLOWED_DATE: OffsetDateTime = LocalDate.now().minusWeeks(13) - .atStartOfDay().atOffset(ZoneOffset.UTC) - } - - fun groups() = feedGroupTable.getAll() - - fun database() = database - - fun getStreams( - groupId: Long, - includePlayedStreams: Boolean, - includePartiallyPlayedStreams: Boolean, - includeFutureStreams: Boolean - ): Maybe> { - return feedTable.getStreams( - groupId, - includePlayedStreams, - includePartiallyPlayedStreams, - if (includeFutureStreams) null else OffsetDateTime.now() - ) - } - - fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold) - - fun outdatedSubscriptionsWithNotificationMode( - outdatedThreshold: OffsetDateTime, - @NotificationMode notificationMode: Int - ) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode) - - fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() - else -> feedTable.notLoadedCountForGroup(groupId) - } - } - - fun outdatedSubscriptionsForGroup( - groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - outdatedThreshold: OffsetDateTime - ) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) - - fun markAsOutdated(subscriptionId: Long) = feedTable - .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) - - fun doesStreamExist(stream: StreamInfoItem): Boolean { - return streamTable.exists(stream.serviceId, stream.url) - } - - fun upsertAll( - subscriptionId: Long, - items: List, - oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE - ) { - val itemsToInsert = items.mapNotNull { stream -> - val uploadDate = stream.uploadDate - - when { - uploadDate == null && stream.streamType == StreamType.LIVE_STREAM -> stream - uploadDate != null && uploadDate.offsetDateTime() >= oldestAllowedDate -> stream - else -> null - } - } - - feedTable.unlinkOldLivestreams(subscriptionId) - - if (itemsToInsert.isNotEmpty()) { - val streamEntities = itemsToInsert.map { StreamEntity(it) } - val streamIds = streamTable.upsertAll(streamEntities) - val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) } - - feedTable.insertAll(feedEntities) - } - - feedTable.setLastUpdatedForSubscription( - FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC)) - ) - } - - fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) { - feedTable.unlinkStreamsOlderThan(oldestAllowedDate) - streamTable.deleteOrphans() - } - - fun clear() { - feedTable.deleteAll() - val deletedOrphans = streamTable.deleteOrphans() - if (DEBUG) { - Log.d( - this::class.java.simpleName, - "clear() → streamTable.deleteOrphans() → $deletedOrphans" - ) - } - } - - // ///////////////////////////////////////////////////////////////////////// - // Feed Groups - // ///////////////////////////////////////////////////////////////////////// - - fun subscriptionIdsForGroup(groupId: Long): Flowable> { - return feedGroupTable.getSubscriptionIdsFor(groupId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { - return Completable - .fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun createGroup(name: String, icon: FeedGroupIcon): Maybe { - return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun getGroup(groupId: Long): Maybe { - return feedGroupTable.getGroup(groupId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable { - return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun deleteGroup(groupId: Long): Completable { - return Completable.fromCallable { feedGroupTable.delete(groupId) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun updateGroupsOrder(groupIdList: List): Completable { - var index = 0L - val orderMap = groupIdList.associateBy({ it }, { index++ }) - - return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun oldestSubscriptionUpdate(groupId: Long): Flowable> { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll() - else -> feedTable.oldestSubscriptionUpdate(groupId) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt deleted file mode 100644 index 89b89a800..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ /dev/null @@ -1,697 +0,0 @@ -/* - * Copyright 2019 Mauricio Colli - * FeedFragment.kt is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.feed - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.graphics.Typeface -import android.graphics.drawable.LayerDrawable -import android.os.Bundle -import android.os.Parcelable -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.lifecycle.ViewModelProvider -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.evernote.android.state.State -import com.xwray.groupie.GroupieAdapter -import com.xwray.groupie.Item -import com.xwray.groupie.OnItemClickListener -import com.xwray.groupie.OnItemLongClickListener -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers -import java.time.OffsetDateTime -import java.util.function.Consumer -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.databinding.FragmentFeedBinding -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil -import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty -import org.schabi.newpipe.fragments.BaseStateFragment -import org.schabi.newpipe.info_list.ItemViewMode -import org.schabi.newpipe.info_list.dialog.InfoItemDialog -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling -import org.schabi.newpipe.ktx.slideUp -import org.schabi.newpipe.local.feed.item.StreamItem -import org.schabi.newpipe.local.feed.service.FeedLoadService -import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.DeviceUtils -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams -import org.schabi.newpipe.util.ThemeHelper.getItemViewMode -import org.schabi.newpipe.util.ThemeHelper.resolveDrawable -import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout - -class FeedFragment : BaseStateFragment() { - private var _feedBinding: FragmentFeedBinding? = null - private val feedBinding get() = _feedBinding!! - - private val disposables = CompositeDisposable() - - private lateinit var viewModel: FeedViewModel - - @State - @JvmField - var listState: Parcelable? = null - - private var groupId = FeedGroupEntity.GROUP_ALL_ID - private var groupName = "" - private var oldestSubscriptionUpdate: OffsetDateTime? = null - - private lateinit var groupAdapter: GroupieAdapter - - private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null - private var updateListViewModeOnResume = false - private var isRefreshing = false - - private var lastNewItemsCount = 0 - - init { - setHasOptionsMenu(true) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) - ?: FeedGroupEntity.GROUP_ALL_ID - groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" - - onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (getString(R.string.list_view_mode_key).equals(key)) { - updateListViewModeOnResume = true - } - } - PreferenceManager.getDefaultSharedPreferences(activity) - .registerOnSharedPreferenceChangeListener(onSettingsChangeListener) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_feed, container, false) - } - - override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { - // super.onViewCreated() calls initListeners() which require the binding to be initialized - _feedBinding = FragmentFeedBinding.bind(rootView) - super.onViewCreated(rootView, savedInstanceState) - - val factory = FeedViewModel.getFactory(requireContext(), groupId) - viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java] - viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } - - groupAdapter = GroupieAdapter().apply { - setOnItemClickListener(listenerStreamItem) - setOnItemLongClickListener(listenerStreamItem) - } - - feedBinding.itemsList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - // Check if we scrolled to the top - if (newState == RecyclerView.SCROLL_STATE_IDLE && - !recyclerView.canScrollVertically(-1) - ) { - if (tryGetNewItemsLoadedButton()?.isVisible == true) { - hideNewItemsLoaded(true) - } - } - } - }) - - feedBinding.itemsList.adapter = groupAdapter - setupListViewMode() - } - - override fun onPause() { - super.onPause() - listState = feedBinding.itemsList.layoutManager?.onSaveInstanceState() - } - - override fun onResume() { - super.onResume() - updateRelativeTimeViews() - - if (updateListViewModeOnResume) { - updateListViewModeOnResume = false - - setupListViewMode() - if (viewModel.stateLiveData.value != null) { - handleResult(viewModel.stateLiveData.value!!) - } - } - } - - private fun setupListViewMode() { - // does everything needed to setup the layouts for grid or list modes - groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCountStreams(context) else 1 - feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { - spanSizeLookup = groupAdapter.spanSizeLookup - } - } - - override fun initListeners() { - super.initListeners() - feedBinding.refreshRootView.setOnClickListener { reloadContent() } - feedBinding.swipeRefreshLayout.setOnRefreshListener { reloadContent() } - feedBinding.newItemsLoadedButton.setOnClickListener { - hideNewItemsLoaded(true) - feedBinding.itemsList.scrollToPosition(0) - } - } - - // ///////////////////////////////////////////////////////////////////////// - // Menu - // ///////////////////////////////////////////////////////////////////////// - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - - activity.supportActionBar?.setDisplayShowTitleEnabled(true) - activity.supportActionBar?.setTitle(R.string.fragment_feed_title) - activity.supportActionBar?.subtitle = groupName - - inflater.inflate(R.menu.menu_feed_fragment, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.menu_item_feed_help) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - val usingDedicatedMethod = sharedPreferences - .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) - val enableDisableButtonText = when { - usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button - else -> R.string.feed_use_dedicated_fetch_method_enable_button - } - - AlertDialog.Builder(requireContext()) - .setMessage(R.string.feed_use_dedicated_fetch_method_help_text) - .setNeutralButton(enableDisableButtonText) { _, _ -> - sharedPreferences.edit { - putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod) - } - } - .setPositiveButton(resources.getString(R.string.ok), null) - .show() - return true - } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { - showStreamVisibilityDialog() - } - - return super.onOptionsItemSelected(item) - } - - private fun showStreamVisibilityDialog() { - val dialogItems = arrayOf( - getString(R.string.feed_show_watched), - getString(R.string.feed_show_partially_watched), - getString(R.string.feed_show_upcoming) - ) - - val checkedDialogItems = booleanArrayOf( - viewModel.getShowPlayedItemsFromPreferences(), - viewModel.getShowPartiallyPlayedItemsFromPreferences(), - viewModel.getShowFutureItemsFromPreferences() - ) - - AlertDialog.Builder(requireContext()) - .setTitle(R.string.feed_hide_streams_title) - .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked -> - checkedDialogItems[which] = isChecked - } - .setPositiveButton(R.string.ok) { _, _ -> - viewModel.setSaveShowPlayedItems(checkedDialogItems[0]) - viewModel.setSaveShowPartiallyPlayedItems(checkedDialogItems[1]) - viewModel.setSaveShowFutureItems(checkedDialogItems[2]) - } - .setNegativeButton(R.string.cancel, null) - .show() - } - - override fun onDestroyOptionsMenu() { - super.onDestroyOptionsMenu() - if ( - (groupName != "") && - (activity?.supportActionBar?.subtitle == groupName) - ) { - activity?.supportActionBar?.subtitle = null - } - } - - override fun onDestroy() { - disposables.dispose() - if (onSettingsChangeListener != null) { - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener) - onSettingsChangeListener = null - } - - super.onDestroy() - - if ( - (groupName != "") && - (activity?.supportActionBar?.subtitle == groupName) - ) { - activity?.supportActionBar?.subtitle = null - } - } - - override fun onDestroyView() { - // Ensure that all animations are canceled - tryGetNewItemsLoadedButton()?.clearAnimation() - - feedBinding.itemsList.adapter = null - _feedBinding = null - super.onDestroyView() - } - - // ////////////////////////////////////////////////////////////////////////// - // Handling - // ////////////////////////////////////////////////////////////////////////// - - override fun showLoading() { - super.showLoading() - feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() - feedBinding.refreshRootView.animate(false, 0) - feedBinding.loadingProgressText.animate(true, 200) - feedBinding.swipeRefreshLayout.isRefreshing = true - isRefreshing = true - } - - override fun hideLoading() { - super.hideLoading() - feedBinding.itemsList.animate(true, 0) - feedBinding.refreshRootView.animate(true, 200) - feedBinding.loadingProgressText.animate(false, 0) - feedBinding.swipeRefreshLayout.isRefreshing = false - isRefreshing = false - } - - override fun showEmptyState() { - super.showEmptyState() - feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() - feedBinding.refreshRootView.animate(true, 200) - feedBinding.loadingProgressText.animate(false, 0) - feedBinding.swipeRefreshLayout.isRefreshing = false - } - - override fun handleResult(result: FeedState) { - when (result) { - is FeedState.ProgressState -> handleProgressState(result) - is FeedState.LoadedState -> handleLoadedState(result) - is FeedState.ErrorState -> if (handleErrorState(result)) return - } - - updateRefreshViewState() - } - - override fun handleError() { - super.handleError() - feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() - feedBinding.refreshRootView.animate(false, 0) - feedBinding.loadingProgressText.animate(false, 0) - feedBinding.swipeRefreshLayout.isRefreshing = false - isRefreshing = false - } - - private fun handleProgressState(progressState: FeedState.ProgressState) { - showLoading() - - val isIndeterminate = progressState.currentProgress == -1 && - progressState.maxProgress == -1 - - feedBinding.loadingProgressText.text = if (!isIndeterminate) { - "${progressState.currentProgress}/${progressState.maxProgress}" - } else if (progressState.progressMessage > 0) { - getString(progressState.progressMessage) - } else { - "∞/∞" - } - - feedBinding.loadingProgressBar.isIndeterminate = isIndeterminate || - (progressState.maxProgress > 0 && progressState.currentProgress == 0) - feedBinding.loadingProgressBar.progress = progressState.currentProgress - - feedBinding.loadingProgressBar.max = progressState.maxProgress - } - - private fun showInfoItemDialog(item: StreamInfoItem) { - val context = context - val activity: Activity? = getActivity() - if (context == null || context.resources == null || activity == null) return - - InfoItemDialog.Builder(activity, context, this, item).create().show() - } - - private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { - override fun onItemClick(item: Item<*>, view: View) { - if (item is StreamItem && !isRefreshing) { - val stream = item.streamWithState.stream - NavigationHelper.openVideoDetailFragment( - requireContext(), - fm, - stream.serviceId, - stream.url, - stream.title, - null, - false - ) - } - } - - override fun onItemLongClick(item: Item<*>, view: View): Boolean { - if (item is StreamItem && !isRefreshing) { - showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) - return true - } - return false - } - } - - @SuppressLint("StringFormatMatches") - private fun handleLoadedState(loadedState: FeedState.LoadedState) { - val itemVersion = when (getItemViewMode(requireContext())) { - ItemViewMode.GRID -> StreamItem.ItemVersion.GRID - ItemViewMode.CARD -> StreamItem.ItemVersion.CARD - else -> StreamItem.ItemVersion.NORMAL - } - loadedState.items.forEach { it.itemVersion = itemVersion } - - // This need to be saved in a variable as the update occurs async - val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate - - groupAdapter.updateAsync(loadedState.items, false) { - oldOldestSubscriptionUpdate?.run { - highlightNewItemsAfter(oldOldestSubscriptionUpdate) - } - } - - listState?.run { - feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) - listState = null - } - - val feedsNotLoaded = loadedState.notLoadedCount > 0 - feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded - if (feedsNotLoaded) { - feedBinding.refreshSubtitleText.text = getString( - R.string.feed_subscription_not_loaded_count, - loadedState.notLoadedCount - ) - } - - if (oldestSubscriptionUpdate != loadedState.oldestUpdate || - (oldestSubscriptionUpdate == null && loadedState.oldestUpdate == null) - ) { - // ignore errors if they have already been handled for the current update - handleItemsErrors(loadedState.itemsErrors) - } - oldestSubscriptionUpdate = loadedState.oldestUpdate - - if (loadedState.items.isEmpty()) { - showEmptyState() - } else { - hideLoading() - } - } - - private fun handleErrorState(errorState: FeedState.ErrorState): Boolean { - return if (errorState.error == null) { - hideLoading() - false - } else { - showError(ErrorInfo(errorState.error, UserAction.REQUESTED_FEED, "Loading feed")) - true - } - } - - private fun handleItemsErrors(errors: List) { - errors.forEachIndexed { i, t -> - if (t is FeedLoadService.RequestException && - t.cause is ContentNotAvailableException - ) { - disposables.add( - Single.fromCallable { - NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() - .getSubscription(t.subscriptionId) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { subscriptionEntity -> - handleFeedNotAvailable( - subscriptionEntity, - t.cause, - errors.subList(i + 1, errors.size) - ) - }, - { throwable -> Log.e(TAG, "Unable to process", throwable) } - ) - ) - // this will be called on the remaining errors by handleFeedNotAvailable() - return@handleItemsErrors - } - } - - if (errors.isNotEmpty()) { - // if no error was a ContentNotAvailableException, show a general error snackbar - ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, "")) - } - } - - private fun handleFeedNotAvailable( - subscriptionEntity: SubscriptionEntity, - cause: Throwable?, - nextItemsErrors: List - ) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val isFastFeedModeEnabled = sharedPreferences.getBoolean( - getString(R.string.feed_use_dedicated_fetch_method_key), - false - ) - - val builder = AlertDialog.Builder(requireContext()) - .setTitle(R.string.feed_load_error) - .setPositiveButton(R.string.unsubscribe) { _, _ -> - SubscriptionManager(requireContext()) - .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url!!) - .subscribe() - handleItemsErrors(nextItemsErrors) - } - .setNegativeButton(R.string.cancel, null) - - var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name) - if (cause is AccountTerminatedException) { - message += "\n" + getString(R.string.feed_load_error_terminated) - } else if (cause is ContentNotAvailableException) { - if (isFastFeedModeEnabled) { - message += "\n" + getString(R.string.feed_load_error_fast_unknown) - builder.setNeutralButton(R.string.feed_use_dedicated_fetch_method_disable_button) { _, _ -> - sharedPreferences.edit { - putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) - } - } - } else if (!isNullOrEmpty(cause.message)) { - message += "\n" + cause.message - } - } - builder.setMessage(message) - .show() - } - - private fun updateRelativeTimeViews() { - updateRefreshViewState() - groupAdapter.notifyItemRangeChanged( - 0, - groupAdapter.itemCount, - StreamItem.UPDATE_RELATIVE_TIME - ) - } - - private fun updateRefreshViewState() { - feedBinding.refreshText.text = getString( - R.string.feed_oldest_subscription_update, - oldestSubscriptionUpdate?.let { Localization.relativeTime(it) } ?: "—" - ) - } - - /** - * Highlights all items that are after the specified time - */ - private fun highlightNewItemsAfter(updateTime: OffsetDateTime) { - var highlightCount = 0 - - var doCheck = true - - for (i in 0 until groupAdapter.itemCount) { - val item = groupAdapter.getItem(i) as StreamItem - - var typeface = Typeface.DEFAULT - var backgroundSupplier = { ctx: Context -> - resolveDrawable(ctx, android.R.attr.selectableItemBackground) - } - if (doCheck) { - // If the uploadDate is null or true we should highlight the item - if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) { - highlightCount++ - - typeface = Typeface.DEFAULT_BOLD - backgroundSupplier = { ctx: Context -> - // Merge the drawables together. Otherwise we would lose the "select" effect - LayerDrawable( - arrayOf( - resolveDrawable(ctx, R.attr.dashed_border), - resolveDrawable(ctx, android.R.attr.selectableItemBackground) - ) - ) - } - } else { - // Decreases execution time due to the order of the items (newest always on top) - // Once a item is is before the updateTime we can skip all following items - doCheck = false - } - } - - // The highlighter has to be always set - // When it's only set on items that are highlighted it will highlight all items - // due to the fact that itemRoot is getting recycled - item.execBindEnd = Consumer { viewBinding -> - val context = viewBinding.itemRoot.context - viewBinding.itemRoot.background = backgroundSupplier.invoke(context) - viewBinding.itemVideoTitleView.typeface = typeface - } - } - - // Force updates all items so that the highlighting is correct - // If this isn't done visible items that are already highlighted will stay in a highlighted - // state until the user scrolls them out of the visible area which causes a update/bind-call - groupAdapter.notifyItemRangeChanged( - 0, - highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount) - ) - - if (highlightCount > 0) { - showNewItemsLoaded() - } - - lastNewItemsCount = highlightCount - } - - private fun showNewItemsLoaded() { - tryGetNewItemsLoadedButton()?.clearAnimation() - tryGetNewItemsLoadedButton() - ?.slideUp( - 250L, - delay = 100, - execOnEnd = { - // Disabled animations would result in immediately hiding the button - // after it showed up - // Context can be null in some cases, so we have to make sure it is not null in - // order to avoid a NullPointerException - context?.let { - if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) { - // Hide the new items button after 10s - hideNewItemsLoaded(true, 10000) - } - } - } - ) - } - - private fun hideNewItemsLoaded(animate: Boolean, delay: Long = 0) { - tryGetNewItemsLoadedButton()?.clearAnimation() - if (animate) { - tryGetNewItemsLoadedButton()?.animate( - false, - 200, - delay = delay, - execOnEnd = { - // Make the layout invisible so that the onScroll toTop method - // only does necessary work - tryGetNewItemsLoadedButton()?.isVisible = false - } - ) - } else { - tryGetNewItemsLoadedButton()?.isVisible = false - } - } - - /** - * The view/button can be disposed/set to null under certain circumstances. - * E.g. when the animation is still in progress but the view got destroyed. - * This method is a helper for such states and can be used in affected code blocks. - */ - private fun tryGetNewItemsLoadedButton(): Button? { - return _feedBinding?.newItemsLoadedButton - } - - // ///////////////////////////////////////////////////////////////////////// - // Load Service Handling - // ///////////////////////////////////////////////////////////////////////// - - override fun doInitialLoadLogic() {} - - override fun reloadContent() { - hideNewItemsLoaded(false) - - getActivity()?.startService( - Intent(requireContext(), FeedLoadService::class.java).apply { - putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) - } - ) - listState = null - } - - companion object { - const val KEY_GROUP_ID = "ARG_GROUP_ID" - const val KEY_GROUP_NAME = "ARG_GROUP_NAME" - - @JvmStatic - fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment { - val feedFragment = FeedFragment() - feedFragment.arguments = bundleOf(KEY_GROUP_ID to groupId, KEY_GROUP_NAME to groupName) - return feedFragment - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt deleted file mode 100644 index 6d6bc9007..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.local.feed - -import androidx.annotation.StringRes -import java.time.OffsetDateTime -import org.schabi.newpipe.local.feed.item.StreamItem - -sealed class FeedState { - data class ProgressState( - val currentProgress: Int = -1, - val maxProgress: Int = -1, - @StringRes val progressMessage: Int = 0 - ) : FeedState() - - data class LoadedState( - val items: List, - val oldestUpdate: OffsetDateTime?, - val notLoadedCount: Long, - val itemsErrors: List - ) : FeedState() - - data class ErrorState( - val error: Throwable? = null - ) : FeedState() -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt deleted file mode 100644 index 19adf6eaa..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.schabi.newpipe.local.feed - -import android.app.Application -import android.content.Context -import androidx.core.content.edit -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.preference.PreferenceManager -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.functions.Function6 -import io.reactivex.rxjava3.processors.BehaviorProcessor -import io.reactivex.rxjava3.schedulers.Schedulers -import java.time.OffsetDateTime -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.App -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.stream.StreamWithState -import org.schabi.newpipe.local.feed.item.StreamItem -import org.schabi.newpipe.local.feed.service.FeedEventManager -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent -import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT - -class FeedViewModel( - private val application: Application, - groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - initialShowPlayedItems: Boolean, - initialShowPartiallyPlayedItems: Boolean, - initialShowFutureItems: Boolean -) : ViewModel() { - private val feedDatabaseManager = FeedDatabaseManager(application) - - private val showPlayedItems = BehaviorProcessor.create() - private val showPlayedItemsFlowable = showPlayedItems - .startWithItem(initialShowPlayedItems) - .distinctUntilChanged() - - private val showPartiallyPlayedItems = BehaviorProcessor.create() - private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems - .startWithItem(initialShowPartiallyPlayedItems) - .distinctUntilChanged() - - private val showFutureItems = BehaviorProcessor.create() - private val showFutureItemsFlowable = showFutureItems - .startWithItem(initialShowFutureItems) - .distinctUntilChanged() - - private val mutableStateLiveData = MutableLiveData() - val stateLiveData: LiveData = mutableStateLiveData - - private var combineDisposable = Flowable - .combineLatest( - FeedEventManager.events(), - showPlayedItemsFlowable, - showPartiallyPlayedItemsFlowable, - showFutureItemsFlowable, - feedDatabaseManager.notLoadedCount(groupId), - feedDatabaseManager.oldestSubscriptionUpdate(groupId), - - Function6 { - t1: FeedEventManager.Event, - t2: Boolean, - t3: Boolean, - t4: Boolean, - t5: Long, - t6: List - -> - return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull()) - } - ) - .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) -> - val streamItems = if (event is SuccessResultEvent || event is IdleEvent) { - feedDatabaseManager - .getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems) - .blockingGet(arrayListOf()) - } else { - arrayListOf() - } - - CombineResultDataHolder(event, streamItems, notLoadedCount, oldestUpdate) - } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> - mutableStateLiveData.postValue( - when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf()) - is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) - is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) - is ErrorResultEvent -> FeedState.ErrorState(event.error) - } - ) - - if (event is ErrorResultEvent || event is SuccessResultEvent) { - FeedEventManager.reset() - } - } - - override fun onCleared() { - super.onCleared() - combineDisposable.dispose() - } - - private data class CombineResultEventHolder( - val t1: FeedEventManager.Event, - val t2: Boolean, - val t3: Boolean, - val t4: Boolean, - val t5: Long, - val t6: OffsetDateTime? - ) - - private data class CombineResultDataHolder( - val t1: FeedEventManager.Event, - val t2: List, - val t3: Long, - val t4: OffsetDateTime? - ) - - fun setSaveShowPlayedItems(showPlayedItems: Boolean) { - this.showPlayedItems.onNext(showPlayedItems) - PreferenceManager.getDefaultSharedPreferences(application).edit { - putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems) - } - } - - fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application) - - fun setSaveShowPartiallyPlayedItems(showPartiallyPlayedItems: Boolean) { - this.showPartiallyPlayedItems.onNext(showPartiallyPlayedItems) - PreferenceManager.getDefaultSharedPreferences(application).edit { - putBoolean(application.getString(R.string.feed_show_partially_watched_items_key), showPartiallyPlayedItems) - } - } - - fun getShowPartiallyPlayedItemsFromPreferences() = getShowPartiallyPlayedItemsFromPreferences(application) - - fun setSaveShowFutureItems(showFutureItems: Boolean) { - this.showFutureItems.onNext(showFutureItems) - PreferenceManager.getDefaultSharedPreferences(application).edit { - putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems) - } - } - - fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application) - - companion object { - private fun getShowPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.feed_show_watched_items_key), true) - - private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true) - - private fun getShowFutureItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.feed_show_future_items_key), true) - - fun getFactory(context: Context, groupId: Long) = viewModelFactory { - initializer { - FeedViewModel( - App.instance, - groupId, - // Read initial value from preferences - getShowPlayedItemsFromPreferences(context.applicationContext), - getShowPartiallyPlayedItemsFromPreferences(context.applicationContext), - getShowFutureItemsFromPreferences(context.applicationContext) - ) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt deleted file mode 100644 index 258a67a4c..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ /dev/null @@ -1,161 +0,0 @@ -package org.schabi.newpipe.local.feed.item - -import android.content.Context -import android.text.TextUtils -import android.view.View -import androidx.core.content.ContextCompat -import androidx.preference.PreferenceManager -import com.xwray.groupie.viewbinding.BindableItem -import java.util.concurrent.TimeUnit -import java.util.function.Consumer -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R -import org.schabi.newpipe.database.stream.StreamWithState -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.databinding.ListStreamItemBinding -import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.StreamTypeUtil -import org.schabi.newpipe.util.image.CoilHelper - -data class StreamItem( - val streamWithState: StreamWithState, - var itemVersion: ItemVersion = ItemVersion.NORMAL -) : BindableItem() { - companion object { - const val UPDATE_RELATIVE_TIME = 1 - } - - private val stream: StreamEntity = streamWithState.stream - private val stateProgressTime: Long? = streamWithState.stateProgressMillis - - /** - * Will be executed at the end of the [StreamItem.bind] (with (ListStreamItemBinding,Int)). - * Can be used e.g. for highlighting a item. - */ - var execBindEnd: Consumer? = null - - override fun getId(): Long = stream.uid - - enum class ItemVersion { NORMAL, MINI, GRID, CARD } - - override fun getLayout(): Int = when (itemVersion) { - ItemVersion.NORMAL -> R.layout.list_stream_item - ItemVersion.MINI -> R.layout.list_stream_mini_item - ItemVersion.GRID -> R.layout.list_stream_grid_item - ItemVersion.CARD -> R.layout.list_stream_card_item - } - - override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) - - override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList) { - if (payloads.contains(UPDATE_RELATIVE_TIME)) { - if (itemVersion != ItemVersion.MINI) { - viewBinding.itemAdditionalDetails.text = - getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) - } - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun bind(viewBinding: ListStreamItemBinding, position: Int) { - viewBinding.itemVideoTitleView.text = stream.title - viewBinding.itemUploaderView.text = stream.uploader - - if (stream.duration > 0) { - viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration) - viewBinding.itemDurationView.setBackgroundColor( - ContextCompat.getColor( - viewBinding.itemDurationView.context, - R.color.duration_background_color - ) - ) - viewBinding.itemDurationView.visibility = View.VISIBLE - - if (stateProgressTime != null) { - viewBinding.itemProgressView.visibility = View.VISIBLE - viewBinding.itemProgressView.max = stream.duration.toInt() - viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt() - } else { - viewBinding.itemProgressView.visibility = View.GONE - } - } else if (StreamTypeUtil.isLiveStream(stream.streamType)) { - viewBinding.itemDurationView.setText(R.string.duration_live) - viewBinding.itemDurationView.setBackgroundColor( - ContextCompat.getColor( - viewBinding.itemDurationView.context, - R.color.live_duration_background_color - ) - ) - viewBinding.itemDurationView.visibility = View.VISIBLE - viewBinding.itemProgressView.visibility = View.GONE - } else { - viewBinding.itemDurationView.visibility = View.GONE - viewBinding.itemProgressView.visibility = View.GONE - } - - CoilHelper.loadThumbnail(viewBinding.itemThumbnailView, stream.thumbnailUrl) - - if (itemVersion != ItemVersion.MINI) { - viewBinding.itemAdditionalDetails.text = - getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) - } - - execBindEnd?.accept(viewBinding) - } - - override fun isLongClickable() = when (stream.streamType) { - AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true - else -> false - } - - private fun getStreamInfoDetailLine(context: Context): String { - var viewsAndDate = "" - val viewCount = stream.viewCount - if (viewCount != null && viewCount >= 0) { - viewsAndDate = when (stream.streamType) { - AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) - LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) - else -> Localization.shortViewCount(context, viewCount) - } - } - val uploadDate = getFormattedRelativeUploadDate(context) - return when { - !TextUtils.isEmpty(uploadDate) -> when { - viewsAndDate.isEmpty() -> uploadDate!! - else -> Localization.concatenateStrings(viewsAndDate, uploadDate) - } - - else -> viewsAndDate - } - } - - private fun getFormattedRelativeUploadDate(context: Context): String? { - val uploadDate = stream.uploadDate - return if (uploadDate != null) { - var formattedRelativeTime = Localization.relativeTime(uploadDate) - - if (MainActivity.DEBUG) { - val key = context.getString(R.string.show_original_time_ago_key) - if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) { - formattedRelativeTime += " (" + stream.textualUploadDate + ")" - } - } - - formattedRelativeTime - } else { - stream.textualUploadDate - } - } - - override fun getSpanSize(spanCount: Int, position: Int): Int { - return if (itemVersion == ItemVersion.GRID) 1 else spanCount - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt deleted file mode 100644 index 11be80f2a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt +++ /dev/null @@ -1,191 +0,0 @@ -package org.schabi.newpipe.local.feed.notifications - -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.preference.PreferenceManager -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.local.feed.service.FeedUpdateInfo -import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.image.CoilHelper - -/** - * Helper for everything related to show notifications about new streams to the user. - */ -class NotificationHelper(val context: Context) { - private val manager = NotificationManagerCompat.from(context) - - /** - * Show notifications for new streams from a single channel. The individual notifications are - * expandable on Android 7.0 and later. - * - * Opening the summary notification will open the corresponding channel page. Opening the - * individual notifications will open the corresponding video. - */ - fun displayNewStreamsNotifications(data: FeedUpdateInfo) { - val newStreams = data.newStreams - val summary = context.resources.getQuantityString( - R.plurals.new_streams, - newStreams.size, - newStreams.size - ) - val summaryBuilder = NotificationCompat.Builder( - context, - context.getString(R.string.streams_notification_channel_id) - ) - .setContentTitle(data.name) - .setContentText(summary) - .setNumber(newStreams.size) - .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) - .setColorized(true) - .setAutoCancel(true) - .setCategory(NotificationCompat.CATEGORY_SOCIAL) - .setGroupSummary(true) - .setGroup(data.url) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) - - // Build a summary notification for Android versions < 7.0 - val style = NotificationCompat.InboxStyle() - .setBigContentTitle(data.name) - newStreams.forEach { style.addLine(it.name) } - summaryBuilder.setStyle(style) - - // open the channel page when clicking on the summary notification - val intent = NavigationHelper - .getChannelIntent(context, data.serviceId, data.url) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - summaryBuilder.setContentIntent( - PendingIntentCompat.getActivity(context, data.pseudoId, intent, 0, false) - ) - - val avatarIcon = - CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white) - summaryBuilder.setLargeIcon(avatarIcon) - - // Show individual stream notifications, set channel icon only if there is actually one - showStreamNotifications(newStreams, data.serviceId, avatarIcon) - // Show summary notification - if (manager.areNotificationsEnabled()) { - manager.notify(data.pseudoId, summaryBuilder.build()) - } - } - - private fun showStreamNotifications( - newStreams: List, - serviceId: Int, - channelIcon: Bitmap? - ) { - if (manager.areNotificationsEnabled()) { - newStreams.forEach { stream -> - val notification = - createStreamNotification(stream, serviceId, channelIcon) - manager.notify(stream.url.hashCode(), notification) - } - } - } - - private fun createStreamNotification( - item: StreamInfoItem, - serviceId: Int, - channelIcon: Bitmap? - ): Notification { - return NotificationCompat.Builder( - context, - context.getString(R.string.streams_notification_channel_id) - ) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setLargeIcon(channelIcon) - .setContentTitle(item.name) - .setContentText(item.uploaderName) - .setGroup(item.uploaderUrl) - .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) - .setColorized(true) - .setAutoCancel(true) - .setCategory(NotificationCompat.CATEGORY_SOCIAL) - .setContentIntent( - // Open the stream link in the player when clicking on the notification. - PendingIntentCompat.getActivity( - context, - item.url.hashCode(), - NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name), - PendingIntent.FLAG_UPDATE_CURRENT, - false - ) - ) - .setSilent(true) // Avoid creating noise for individual stream notifications. - .build() - } - - companion object { - /** - * Check whether notifications are enabled on the device. - * Users can disable them via the system settings for a single app. - * If this is the case, the app cannot create any notifications - * and display them to the user. - *
- * On Android 26 and above, notification channels are used by NewPipe. - * These can be configured by the user, too. - * The notification channel for new streams is also checked by this method. - * - * @param context Context - * @return true if notifications are allowed and can be displayed; - * false otherwise - */ - fun areNotificationsEnabledOnDevice(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = context.getString(R.string.streams_notification_channel_id) - val manager = context.getSystemService()!! - val enabled = manager.areNotificationsEnabled() - val channel = manager.getNotificationChannel(channelId) - enabled && channel?.importance != NotificationManager.IMPORTANCE_NONE - } else { - NotificationManagerCompat.from(context).areNotificationsEnabled() - } - } - - /** - * Whether the user enabled the notifications for new streams in the app settings. - */ - @JvmStatic - fun areNewStreamsNotificationsEnabled(context: Context): Boolean { - return ( - PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.enable_streams_notifications), false) && - areNotificationsEnabledOnDevice(context) - ) - } - - /** - * Open the system's notification settings for NewPipe on Android Oreo (API 26) and later. - * Open the system's app settings for NewPipe on previous Android versions. - */ - fun openNewPipeSystemNotificationSettings(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } else { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = "package:${context.packageName}".toUri() - context.startActivity(intent) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt deleted file mode 100644 index d1fd29945..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt +++ /dev/null @@ -1,173 +0,0 @@ -package org.schabi.newpipe.local.feed.notifications - -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.rxjava3.RxWorker -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.App -import org.schabi.newpipe.R -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil -import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.local.feed.service.FeedLoadManager -import org.schabi.newpipe.local.feed.service.FeedLoadService - -/* - * Worker which checks for new streams of subscribed channels - * in intervals which can be set by the user in the settings. - */ -class NotificationWorker( - appContext: Context, - workerParams: WorkerParameters -) : RxWorker(appContext, workerParams) { - - private val notificationHelper by lazy { - NotificationHelper(appContext) - } - private val feedLoadManager = FeedLoadManager(appContext) - - override fun createWork(): Single = if (areNotificationsEnabled(applicationContext)) { - feedLoadManager.startLoading( - ignoreOutdatedThreshold = true, - groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED - ) - .doOnSubscribe { showLoadingFeedForegroundNotification() } - .map { feed -> - // filter out feedUpdateInfo items (i.e. channels) with nothing new - feed.mapNotNull { - it.value?.takeIf { feedUpdateInfo -> - feedUpdateInfo.newStreams.isNotEmpty() - } - } - } - .observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread - .map { feedUpdateInfoList -> - // display notifications for each feedUpdateInfo (i.e. channel) - feedUpdateInfoList.forEach { feedUpdateInfo -> - notificationHelper.displayNewStreamsNotifications(feedUpdateInfo) - } - return@map Result.success() - } - .doOnError { throwable -> - Log.e(TAG, "Error while displaying streams notifications", throwable) - ErrorUtil.createNotification( - applicationContext, - ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker") - ) - } - .onErrorReturnItem(Result.failure()) - } else { - // the user can disable streams notifications in the device's app settings - Single.just(Result.success()) - } - - private fun showLoadingFeedForegroundNotification() { - val notification = NotificationCompat.Builder( - applicationContext, - applicationContext.getString(R.string.notification_channel_id) - ).setOngoing(true) - .setProgress(-1, -1, true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setContentTitle(applicationContext.getString(R.string.feed_notification_loading)) - .build() - // ServiceInfo constants are not used below Android Q, so 0 is set here - val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 - setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType)) - } - - companion object { - - private val TAG = NotificationWorker::class.java.simpleName - private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications" - - private fun areNotificationsEnabled(context: Context) = NotificationHelper.areNewStreamsNotificationsEnabled(context) && - NotificationHelper.areNotificationsEnabledOnDevice(context) - - /** - * Schedules a task for the [NotificationWorker] - * if the (device and in-app) notifications are enabled, - * otherwise [cancel]s all scheduled tasks. - */ - @JvmStatic - fun initialize(context: Context) { - if (areNotificationsEnabled(context)) { - schedule(context) - } else { - cancel(context) - } - } - - /** - * @param context the context to use - * @param options configuration options for the scheduler - * @param force Force the scheduler to use the new options - * by replacing the previously used worker. - */ - fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) { - val constraints = Constraints.Builder() - .setRequiredNetworkType( - if (options.isRequireNonMeteredNetwork) { - NetworkType.UNMETERED - } else { - NetworkType.CONNECTED - } - ).build() - - val request = PeriodicWorkRequest.Builder( - NotificationWorker::class.java, - options.interval, - TimeUnit.MILLISECONDS - ).setConstraints(constraints) - .addTag(WORK_TAG) - .build() - - WorkManager.getInstance(context) - .enqueueUniquePeriodicWork( - WORK_TAG, - if (force) { - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE - } else { - ExistingPeriodicWorkPolicy.KEEP - }, - request - ) - } - - @JvmStatic - fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context)) - - /** - * Check for new streams immediately - */ - @JvmStatic - fun runNow(context: Context) { - val request = OneTimeWorkRequestBuilder() - .addTag(WORK_TAG) - .build() - WorkManager.getInstance(context).enqueue(request) - } - - /** - * Cancels all current work related to the [NotificationWorker]. - */ - @JvmStatic - fun cancel(context: Context) { - WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt deleted file mode 100644 index 6d5f12b2b..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.schabi.newpipe.local.feed.notifications - -import android.content.Context -import androidx.preference.PreferenceManager -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.R -import org.schabi.newpipe.ktx.getStringSafe - -/** - * Information for the Scheduler which checks for new streams. - * See [NotificationWorker] - */ -data class ScheduleOptions( - val interval: Long, - val isRequireNonMeteredNetwork: Boolean -) { - - companion object { - - fun from(context: Context): ScheduleOptions { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - return ScheduleOptions( - interval = TimeUnit.SECONDS.toMillis( - preferences.getStringSafe( - context.getString(R.string.streams_notifications_interval_key), - context.getString(R.string.streams_notifications_interval_default) - ).toLong() - ), - isRequireNonMeteredNetwork = preferences.getString( - context.getString(R.string.streams_notifications_network_key), - context.getString(R.string.streams_notifications_network_default) - ) == context.getString(R.string.streams_notifications_network_wifi) - ) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt deleted file mode 100644 index 952a59b9a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.schabi.newpipe.local.feed.service - -import androidx.annotation.StringRes -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.processors.BehaviorProcessor -import java.util.concurrent.atomic.AtomicBoolean -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent - -object FeedEventManager { - private var processor: BehaviorProcessor = BehaviorProcessor.create() - private var ignoreUpstream = AtomicBoolean() - private var eventsFlowable = processor.startWithItem(IdleEvent) - - fun postEvent(event: Event) { - processor.onNext(event) - } - - fun events(): Flowable { - return eventsFlowable.filter { !ignoreUpstream.get() } - } - - fun reset() { - ignoreUpstream.set(true) - postEvent(IdleEvent) - ignoreUpstream.set(false) - } - - sealed class Event { - data object IdleEvent : Event() - data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { - constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) - } - - data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event() - data class ErrorResultEvent(val error: Throwable) : Event() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt deleted file mode 100644 index 1e1bdcf16..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt +++ /dev/null @@ -1,371 +0,0 @@ -package org.schabi.newpipe.local.feed.service - -import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceManager -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Notification -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.processors.PublishProcessor -import io.reactivex.rxjava3.schedulers.Schedulers -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.Info -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.feed.FeedInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.ktx.getStringSafe -import org.schabi.newpipe.local.feed.FeedDatabaseManager -import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.ChannelTabHelper -import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo -import org.schabi.newpipe.util.ExtractorHelper.getChannelTab -import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems - -class FeedLoadManager(private val context: Context) { - - private val subscriptionManager = SubscriptionManager(context) - private val feedDatabaseManager = FeedDatabaseManager(context) - - private val notificationUpdater = PublishProcessor.create() - private val currentProgress = AtomicInteger(-1) - private val maxProgress = AtomicInteger(-1) - private val cancelSignal = AtomicBoolean() - private val feedResultsHolder = FeedResultsHolder() - - val notification: Flowable = notificationUpdater.map { description -> - FeedLoadState(description, maxProgress.get(), currentProgress.get()) - } - - /** - * Start checking for new streams of a subscription group. - * @param groupId The ID of the subscription group to load. When using - * [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using - * [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams - * are loaded. Using an id of a group created by the user results in that specific group to be - * loaded. - * @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated - * within the `feed_update_threshold` are checked for updates. This threshold can be set by - * the user in the app settings. When `true`, all subscriptions are checked for new streams. - */ - fun startLoading( - groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - ignoreOutdatedThreshold: Boolean = false - ): Single>> { - val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val useFeedExtractor = defaultSharedPreferences.getBoolean( - context.getString(R.string.feed_use_dedicated_fetch_method_key), - false - ) - - val outdatedThreshold = if (ignoreOutdatedThreshold) { - OffsetDateTime.now(ZoneOffset.UTC) - } else { - val thresholdOutdatedSeconds = defaultSharedPreferences.getStringSafe( - context.getString(R.string.feed_update_threshold_key), - context.getString(R.string.feed_update_threshold_default_value) - ).toInt() - OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong()) - } - - /** - * subscriptions which have not been updated within the feed updated threshold - */ - val outdatedSubscriptions = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions( - outdatedThreshold - ) - - GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( - outdatedThreshold, - NotificationMode.ENABLED - ) - - else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) - } - - // like `currentProgress`, but counts the number of YouTube extractions that have begun, so - // they can be properly throttled every once in a while (see doOnNext below) - val youtubeExtractionCount = AtomicInteger() - - return outdatedSubscriptions - .take(1) - .doOnNext { - currentProgress.set(0) - maxProgress.set(it.size) - } - .filter { it.isNotEmpty() } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { - notificationUpdater.onNext("") - broadcastProgress() - } - .observeOn(Schedulers.io()) - // Randomize user subscription ordering to attempt to resist fingerprinting - .flatMap { Flowable.fromIterable(it.shuffled()) } - .takeWhile { !cancelSignal.get() } - .doOnNext { subscriptionEntity -> - // throttle YouTube extractions once every BATCH_SIZE to avoid being rate limited - if (subscriptionEntity.serviceId == ServiceList.YouTube.serviceId) { - val previousCount = youtubeExtractionCount.getAndIncrement() - if (previousCount != 0 && previousCount % BATCH_SIZE == 0) { - Thread.sleep(DELAY_BETWEEN_BATCHES_MILLIS.random()) - } - } - } - .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) - .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) - .filter { !cancelSignal.get() } - .map { subscriptionEntity -> - loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences) - } - .sequential() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext(NotificationConsumer()) - .observeOn(Schedulers.io()) - .buffer(BUFFER_COUNT_BEFORE_INSERT) - .doOnNext(DatabaseConsumer()) - .subscribeOn(Schedulers.io()) - .toList() - .flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) } - } - - fun cancel() { - cancelSignal.set(true) - } - - private fun broadcastProgress() { - FeedEventManager.postEvent( - FeedEventManager.Event.ProgressEvent( - currentProgress.get(), - maxProgress.get() - ) - ) - } - - private fun loadStreams( - subscriptionEntity: SubscriptionEntity, - useFeedExtractor: Boolean, - defaultSharedPreferences: SharedPreferences - ): Notification { - var error: Throwable? = null - val storeOriginalErrorAndRethrow = { e: Throwable -> - // keep original to prevent blockingGet() from wrapping it into RuntimeException - error = e - throw e - } - - try { - // check for and load new streams - // either by using the dedicated feed method or by getting the channel info - var originalInfo: Info? = null - var streams: List? = null - val errors = ArrayList() - - if (useFeedExtractor) { - NewPipe.getService(subscriptionEntity.serviceId) - .getFeedExtractor(subscriptionEntity.url) - ?.also { feedExtractor -> - // the user wants to use a feed extractor and there is one, use it - val feedInfo = FeedInfo.getInfo(feedExtractor) - errors.addAll(feedInfo.errors) - originalInfo = feedInfo - streams = feedInfo.relatedItems - } - } - - if (originalInfo == null) { - // use the normal channel tabs extractor if either the user wants it, or - // the current service does not have a dedicated feed extractor - - val channelInfo = getChannelInfo( - subscriptionEntity.serviceId, - subscriptionEntity.url, - true - ) - .onErrorReturn(storeOriginalErrorAndRethrow) - .blockingGet() - errors.addAll(channelInfo.errors) - originalInfo = channelInfo - - streams = channelInfo.tabs - .filter { tab -> - ChannelTabHelper.fetchFeedChannelTab( - context, - defaultSharedPreferences, - tab - ) - } - .map { - Pair( - getChannelTab(subscriptionEntity.serviceId, it, true) - .onErrorReturn(storeOriginalErrorAndRethrow) - .blockingGet(), - it - ) - } - .flatMap { (channelTabInfo, linkHandler) -> - errors.addAll(channelTabInfo.errors) - if (channelTabInfo.relatedItems.isEmpty() && - channelTabInfo.nextPage != null - ) { - val infoItemsPage = getMoreChannelTabItems( - subscriptionEntity.serviceId, - linkHandler, - channelTabInfo.nextPage - ) - .blockingGet() - - errors.addAll(infoItemsPage.errors) - return@flatMap infoItemsPage.items - } else { - return@flatMap channelTabInfo.relatedItems - } - } - .filterIsInstance() - } - - return Notification.createOnNext( - FeedUpdateInfo( - subscriptionEntity, - originalInfo!!, - streams!!, - errors - ) - ) - } catch (e: Throwable) { - val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" - val wrapper = FeedLoadService.RequestException( - subscriptionEntity.uid, - request, - // do this to prevent blockingGet() from wrapping into RuntimeException - error ?: e - ) - return Notification.createOnError(wrapper) - } - } - - /** - * Keep the feed and the stream tables small - * to reduce loading times when trying to display the feed. - *
- * Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE]. - * Remove streams from the database which are not linked / used by any table. - */ - private fun postProcessFeed() = Completable.fromRunnable { - FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) - feedDatabaseManager.removeOrphansOrOlderStreams() - - FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors)) - }.doOnSubscribe { - currentProgress.set(-1) - maxProgress.set(-1) - - notificationUpdater.onNext(context.getString(R.string.feed_processing_message)) - FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) - }.subscribeOn(Schedulers.io()) - - private inner class NotificationConsumer : Consumer> { - override fun accept(item: Notification) { - currentProgress.incrementAndGet() - notificationUpdater.onNext(item.value?.name.orEmpty()) - - broadcastProgress() - } - } - - private inner class DatabaseConsumer : Consumer>> { - - override fun accept(list: List>) { - feedDatabaseManager.database().runInTransaction { - for (notification in list) { - when { - notification.isOnNext -> { - val info = notification.value!! - - notification.value!!.newStreams = filterNewStreams(info.streams) - - feedDatabaseManager.upsertAll(info.uid, info.streams) - subscriptionManager.updateFromInfo(info) - - if (info.errors.isNotEmpty()) { - feedResultsHolder.addErrors( - info.errors.map { - FeedLoadService.RequestException( - info.uid, - "${info.serviceId}:${info.url}", - it - ) - } - ) - feedDatabaseManager.markAsOutdated(info.uid) - } - } - - notification.isOnError -> { - val error = notification.error - feedResultsHolder.addError(error!!) - - if (error is FeedLoadService.RequestException) { - feedDatabaseManager.markAsOutdated(error.subscriptionId) - } - } - } - } - } - } - - private fun filterNewStreams(list: List): List { - return list.filter { - !feedDatabaseManager.doesStreamExist(it) && - it.uploadDate != null && - // Streams older than this date are automatically removed from the feed. - // Therefore, streams which are not in the database, - // but older than this date, are considered old. - it.uploadDate!!.offsetDateTime().isAfter( - FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE - ) - } - } - } - - companion object { - - /** - * Constant used to check for updates of subscriptions with [NotificationMode.ENABLED]. - */ - const val GROUP_NOTIFICATION_ENABLED = -2L - - /** - * How many extractions will be running in parallel. - */ - private const val PARALLEL_EXTRACTIONS = 3 - - /** - * How many YouTube extractions to perform before waiting [DELAY_BETWEEN_BATCHES_MILLIS] - * to avoid being rate limited - */ - private const val BATCH_SIZE = 50 - - /** - * Wait a random delay in this range once every [BATCH_SIZE] YouTube extractions to avoid - * being rate limited - */ - private val DELAY_BETWEEN_BATCHES_MILLIS = (6000L..12000L) - - /** - * Number of items to buffer to mass-insert in the database. - */ - private const val BUFFER_COUNT_BEFORE_INSERT = 20 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt deleted file mode 100644 index bd128b3bc..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright 2019 Mauricio Colli - * FeedLoadService.kt is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.feed.service - -import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.Function -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.App -import org.schabi.newpipe.MainActivity.DEBUG -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent - -class FeedLoadService : Service() { - companion object { - private val TAG = FeedLoadService::class.java.simpleName - const val NOTIFICATION_ID = 7293450 - private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL" - - /** - * How often the notification will be updated. - */ - private const val NOTIFICATION_SAMPLING_PERIOD = 1500 - - const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" - } - - private var loadingDisposable: Disposable? = null - private var notificationDisposable: Disposable? = null - - private lateinit var feedLoadManager: FeedLoadManager - - // ///////////////////////////////////////////////////////////////////////// - // Lifecycle - // ///////////////////////////////////////////////////////////////////////// - - override fun onCreate() { - super.onCreate() - feedLoadManager = FeedLoadManager(this) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (DEBUG) { - Log.d( - TAG, - "onStartCommand() called with: intent = [" + intent + "]," + - " flags = [" + flags + "], startId = [" + startId + "]" - ) - } - - if (intent == null || loadingDisposable != null) { - return START_NOT_STICKY - } - - setupNotification() - setupBroadcastReceiver() - - val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) - loadingDisposable = feedLoadManager.startLoading(groupId) - .observeOn(AndroidSchedulers.mainThread()) - .doOnSubscribe { - startForeground(NOTIFICATION_ID, notificationBuilder.build()) - } - .subscribe { _, error: Throwable? -> - // explicitly mark error as nullable - if (error != null) { - Log.e(TAG, "Error while storing result", error) - handleError(error) - return@subscribe - } - stopService() - } - return START_NOT_STICKY - } - - private fun disposeAll() { - unregisterReceiver(broadcastReceiver) - loadingDisposable?.dispose() - notificationDisposable?.dispose() - } - - private fun stopService() { - disposeAll() - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onBind(intent: Intent): IBinder? { - return null - } - - // ///////////////////////////////////////////////////////////////////////// - // Loading & Handling - // ///////////////////////////////////////////////////////////////////////// - - class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) - - // ///////////////////////////////////////////////////////////////////////// - // Notification - // ///////////////////////////////////////////////////////////////////////// - - private lateinit var notificationManager: NotificationManagerCompat - private lateinit var notificationBuilder: NotificationCompat.Builder - - private fun createNotification(): NotificationCompat.Builder { - val cancelActionIntent = PendingIntentCompat - .getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0, false) - - return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) - .setOngoing(true) - .setProgress(-1, -1, true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(0, getString(R.string.cancel), cancelActionIntent) - .setContentTitle(getString(R.string.feed_notification_loading)) - } - - private fun setupNotification() { - notificationManager = NotificationManagerCompat.from(this) - notificationBuilder = createNotification() - - val throttleAfterFirstEmission = Function { flow: Flowable -> - flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) - } - - notificationDisposable = feedLoadManager.notification - .publish(throttleAfterFirstEmission) - .observeOn(AndroidSchedulers.mainThread()) - .doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) } - .subscribe(this::updateNotificationProgress) - } - - private fun updateNotificationProgress(state: FeedLoadState) { - notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1) - - if (state.maxProgress == -1) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) - if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription) - notificationBuilder.setContentText(state.updateDescription) - } else { - val progressText = state.currentProgress.toString() + "/" + state.maxProgress - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (state.updateDescription.isNotEmpty()) { - notificationBuilder.setContentText("${state.updateDescription} ($progressText)") - } - } else { - notificationBuilder.setContentInfo(progressText) - if (state.updateDescription.isNotEmpty()) { - notificationBuilder.setContentText(state.updateDescription) - } - } - } - - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) - } - } - - // ///////////////////////////////////////////////////////////////////////// - // Notification Actions - // ///////////////////////////////////////////////////////////////////////// - - private lateinit var broadcastReceiver: BroadcastReceiver - - private fun setupBroadcastReceiver() { - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == ACTION_CANCEL) { - feedLoadManager.cancel() - } - } - } - ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED) - } - - // ///////////////////////////////////////////////////////////////////////// - // Error handling - // ///////////////////////////////////////////////////////////////////////// - - private fun handleError(error: Throwable) { - postEvent(ErrorResultEvent(error)) - stopService() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt deleted file mode 100644 index 2aedf0925..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.local.feed.service - -data class FeedLoadState( - val updateDescription: String, - val maxProgress: Int, - val currentProgress: Int -) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt deleted file mode 100644 index 729f2c009..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.schabi.newpipe.local.feed.service - -class FeedResultsHolder { - /** - * List of errors that may have happen during loading. - */ - val itemsErrors: List - get() = itemsErrorsHolder - - private val itemsErrorsHolder: MutableList = ArrayList() - - fun addError(error: Throwable) { - itemsErrorsHolder.add(error) - } - - fun addErrors(errors: List) { - itemsErrorsHolder.addAll(errors) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt deleted file mode 100644 index fb4a27913..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.schabi.newpipe.local.feed.service - -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.Info -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.util.image.ImageStrategy - -/** - * Instances of this class might stay around in memory for some time while fetching the feed, - * because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain - * as little data as possible to avoid out of memory errors. In particular, avoid storing whole - * [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers. - */ -data class FeedUpdateInfo( - val uid: Long, - @NotificationMode - val notificationMode: Int, - val name: String, - val avatarUrl: String?, - val url: String, - val serviceId: Int, - // description and subscriberCount are null if the constructor info is from the fast feed method - val description: String?, - val subscriberCount: Long?, - val streams: List, - val errors: List -) { - constructor( - subscription: SubscriptionEntity, - info: Info, - streams: List, - errors: List - ) : this( - uid = subscription.uid, - notificationMode = subscription.notificationMode, - name = info.name, - avatarUrl = (info as? ChannelInfo)?.avatars?.let { - // if the newly fetched info is not from fast feed, then it contains updated avatars - ImageStrategy.imageListToDbUrl(it) - } ?: subscription.avatarUrl, - url = info.url, - serviceId = info.serviceId, - // there is no description and subscriberCount in the fast feed - description = (info as? ChannelInfo)?.description, - subscriberCount = (info as? ChannelInfo)?.subscriberCount, - streams = streams, - errors = errors - ) - - /** - * Integer id, can be used as notification id, etc. - */ - val pseudoId: Int - get() = url.hashCode() - - lateinit var newStreams: List -} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java deleted file mode 100644 index ed3cf548f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ /dev/null @@ -1,318 +0,0 @@ -package org.schabi.newpipe.local.history; - -/* - * Copyright (C) Mauricio Colli 2018 - * HistoryRecordManager.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; -import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; -import org.schabi.newpipe.database.history.model.SearchHistoryEntry; -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; -import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -import org.schabi.newpipe.database.stream.dao.StreamDAO; -import org.schabi.newpipe.database.stream.dao.StreamStateDAO; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.local.feed.FeedViewModel; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class HistoryRecordManager { - private final AppDatabase database; - private final StreamDAO streamTable; - private final StreamHistoryDAO streamHistoryTable; - private final SearchHistoryDAO searchHistoryTable; - private final StreamStateDAO streamStateTable; - private final SharedPreferences sharedPreferences; - private final String searchHistoryKey; - private final String streamHistoryKey; - - public HistoryRecordManager(final Context context) { - database = NewPipeDatabase.getInstance(context); - streamTable = database.streamDAO(); - streamHistoryTable = database.streamHistoryDAO(); - searchHistoryTable = database.searchHistoryDAO(); - streamStateTable = database.streamStateDAO(); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - searchHistoryKey = context.getString(R.string.enable_search_history_key); - streamHistoryKey = context.getString(R.string.enable_watch_history_key); - } - - /////////////////////////////////////////////////////// - // Watch History - /////////////////////////////////////////////////////// - - /** - * Marks a stream item as watched such that it is hidden from the feed if watched videos are - * hidden. Adds a history entry and updates the stream progress to 100%. - * - * @see FeedViewModel#setSaveShowPlayedItems - * @param info the item to mark as watched - * @return a Maybe containing the ID of the item if successful - */ - public Maybe markAsWatched(final StreamInfoItem info) { - if (!isStreamHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final long streamId; - final long duration; - // Duration will not exist if the item was loaded with fast mode, so fetch it if empty - if (info.getDuration() < 0) { - final StreamInfo completeInfo = ExtractorHelper.getStreamInfo( - info.getServiceId(), - info.getUrl(), - false - ) - .subscribeOn(Schedulers.io()) - .blockingGet(); - duration = completeInfo.getDuration(); - streamId = streamTable.upsert(new StreamEntity(completeInfo)); - } else { - duration = info.getDuration(); - streamId = streamTable.upsert(new StreamEntity(info)); - } - - // Update the stream progress to the full duration of the video - final StreamStateEntity entity = new StreamStateEntity( - streamId, - duration * 1000 - ); - streamStateTable.upsert(entity); - - // Add a history entry - final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - if (latestEntry == null) { - // never actually viewed: add history entry but with 0 views - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0)); - } else { - return 0L; - } - })).subscribeOn(Schedulers.io()); - } - - public Maybe onViewed(final StreamInfo info) { - if (!isStreamHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - - if (latestEntry != null) { - streamHistoryTable.delete(latestEntry); - latestEntry.setAccessDate(currentTime); - latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); - return streamHistoryTable.insert(latestEntry); - } else { - // just viewed for the first time: set 1 view - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); - } - })).subscribeOn(Schedulers.io()); - } - - public Completable deleteStreamHistoryAndState(final long streamId) { - return Completable.fromAction(() -> { - streamStateTable.deleteState(streamId); - streamHistoryTable.deleteStreamHistory(streamId); - }).subscribeOn(Schedulers.io()); - } - - public Single deleteWholeStreamHistory() { - return Single.fromCallable(streamHistoryTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Single deleteCompleteStreamStateHistory() { - return Single.fromCallable(streamStateTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getStreamHistorySortedById() { - return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io()); - } - - public Flowable> getStreamStatistics() { - return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); - } - - private boolean isStreamHistoryEnabled() { - return sharedPreferences.getBoolean(streamHistoryKey, false); - } - - /////////////////////////////////////////////////////// - // Search History - /////////////////////////////////////////////////////// - - public Maybe onSearched(final int serviceId, final String search) { - if (!isSearchHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); - - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); - if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { - latestEntry.setCreationDate(currentTime); - return (long) searchHistoryTable.update(latestEntry); - } else { - return searchHistoryTable.insert(newEntry); - } - })).subscribeOn(Schedulers.io()); - } - - public Single deleteSearchHistory(final String search) { - return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) - .subscribeOn(Schedulers.io()); - } - - public Single deleteCompleteSearchHistory() { - return Single.fromCallable(searchHistoryTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getRelatedSearches(final String query, - final int similarQueryLimit, - final int uniqueQueryLimit) { - return query.length() > 0 - ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) - : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); - } - - private boolean isSearchHistoryEnabled() { - return sharedPreferences.getBoolean(searchHistoryKey, false); - } - - /////////////////////////////////////////////////////// - // Stream State History - /////////////////////////////////////////////////////// - - public Maybe loadStreamState(final PlayQueueItem queueItem) { - return queueItem.getStream() - .map(info -> streamTable.upsert(new StreamEntity(info))) - .flatMapPublisher(streamStateTable::getState) - .firstElement() - .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid(queueItem.getDuration())) - .subscribeOn(Schedulers.io()); - } - - public Maybe loadStreamState(final StreamInfo info) { - return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) - .flatMapPublisher(streamStateTable::getState) - .firstElement() - .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid(info.getDuration())) - .subscribeOn(Schedulers.io()); - } - - public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { - return Completable.fromAction(() -> database.runInTransaction(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis); - if (state.isValid(info.getDuration())) { - streamStateTable.upsert(state); - } - })).subscribeOn(Schedulers.io()); - } - - public Single loadStreamState(final InfoItem info) { - return Single.fromCallable(() -> { - final List entities = streamTable - .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); - if (entities.isEmpty()) { - return new StreamStateEntity[]{null}; - } - final List states = streamStateTable - .getState(entities.get(0).getUid()).blockingFirst(); - if (states.isEmpty()) { - return new StreamStateEntity[]{null}; - } - return new StreamStateEntity[]{states.get(0)}; - }).subscribeOn(Schedulers.io()); - } - - public Single> loadLocalStreamStateBatch( - final List items) { - return Single.fromCallable(() -> { - final List result = new ArrayList<>(items.size()); - for (final LocalItem item : items) { - final long streamId; - if (item instanceof StreamStatisticsEntry) { - streamId = ((StreamStatisticsEntry) item).getStreamId(); - } else if (item instanceof PlaylistStreamEntity) { - streamId = ((PlaylistStreamEntity) item).getStreamUid(); - } else if (item instanceof PlaylistStreamEntry) { - streamId = ((PlaylistStreamEntry) item).getStreamId(); - } else { - result.add(null); - continue; - } - final List states = streamStateTable.getState(streamId) - .blockingFirst(); - if (states.isEmpty()) { - result.add(null); - } else { - result.add(states.get(0)); - } - } - return result; - }).subscribeOn(Schedulers.io()); - } - - /////////////////////////////////////////////////////// - // Utility - /////////////////////////////////////////////////////// - - public Single removeOrphanedRecords() { - return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java deleted file mode 100644 index 43b7f1c0d..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ /dev/null @@ -1,392 +0,0 @@ -package org.schabi.newpipe.local.history; - -import android.content.Context; -import android.os.Bundle; -import android.os.Parcelable; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.evernote.android.state.State; -import com.google.android.material.snackbar.Snackbar; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; -import org.schabi.newpipe.local.BaseLocalListFragment; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.settings.HistorySettingsFragment; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.PlayButtonHelper; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.function.Supplier; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class StatisticsPlaylistFragment - extends BaseLocalListFragment, Void> - implements PlaylistControlViewHolder { - private final CompositeDisposable disposables = new CompositeDisposable(); - @State - Parcelable itemsListState; - private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED; - - private StatisticPlaylistControlBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - /* Used for independent events */ - private Subscription databaseSubscription; - private HistoryRecordManager recordManager; - - private List processResult(final List results) { - final Comparator comparator; - switch (sortMode) { - case LAST_PLAYED: - comparator = Comparator.comparing(StreamStatisticsEntry::getLatestAccessDate); - break; - case MOST_PLAYED: - comparator = Comparator.comparingLong(StreamStatisticsEntry::getWatchCount); - break; - default: - return null; - } - Collections.sort(results, comparator.reversed()); - return results; - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Creation - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - recordManager = new HistoryRecordManager(getContext()); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); - } - - @Override - public void onResume() { - super.onResume(); - if (activity != null) { - setTitle(activity.getString(R.string.title_activity_history)); - } - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_history, menu); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Views - /////////////////////////////////////////////////////////////////////////// - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - if (!useAsFrontPage) { - setTitle(getString(R.string.title_last_played)); - } - } - - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(), - itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; - } - - @Override - protected void initListeners() { - super.initListeners(); - - itemListAdapter.setSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final LocalItem selectedItem) { - if (selectedItem instanceof StreamStatisticsEntry) { - final StreamEntity item = - ((StreamStatisticsEntry) selectedItem).getStreamEntity(); - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - item.getServiceId(), item.getUrl(), item.getTitle(), null, false); - } - } - - @Override - public void held(final LocalItem selectedItem) { - if (selectedItem instanceof StreamStatisticsEntry) { - showInfoItemDialog((StreamStatisticsEntry) selectedItem); - } - } - }); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == R.id.action_history_clear) { - HistorySettingsFragment - .openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables); - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Loading - /////////////////////////////////////////////////////////////////////////// - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - recordManager.getStreamStatistics() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHistoryObserver()); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Destruction - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onPause() { - super.onPause(); - itemsListState = Objects.requireNonNull(itemsList.getLayoutManager()).onSaveInstanceState(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - if (itemListAdapter != null) { - itemListAdapter.unsetSelectedListener(); - } - - headerBinding = null; - playlistControlBinding = null; - - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - databaseSubscription = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - recordManager = null; - itemsListState = null; - } - - /////////////////////////////////////////////////////////////////////////// - // Statistics Loader - /////////////////////////////////////////////////////////////////////////// - - private Subscriber> getHistoryObserver() { - return new Subscriber>() { - @Override - public void onSubscribe(final Subscription s) { - showLoading(); - - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - databaseSubscription = s; - databaseSubscription.request(1); - } - - @Override - public void onNext(final List streams) { - handleResult(streams); - if (databaseSubscription != null) { - databaseSubscription.request(1); - } - } - - @Override - public void onError(final Throwable exception) { - showError( - new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics")); - } - - @Override - public void onComplete() { - } - }; - } - - @Override - public void handleResult(@NonNull final List result) { - super.handleResult(result); - if (itemListAdapter == null) { - return; - } - - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - - itemListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - showEmptyState(); - return; - } - - itemListAdapter.addItems(processResult(result)); - if (itemsListState != null && itemsList.getLayoutManager() != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - - PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); - - headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); - - hideLoading(); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - /////////////////////////////////////////////////////////////////////////// - - @Override - protected void resetFragment() { - super.resetFragment(); - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void toggleSortMode() { - if (sortMode == StatisticSortMode.LAST_PLAYED) { - sortMode = StatisticSortMode.MOST_PLAYED; - setTitle(getString(R.string.title_most_played)); - headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history); - headerBinding.sortButtonText.setText(R.string.title_last_played); - } else { - sortMode = StatisticSortMode.LAST_PLAYED; - setTitle(getString(R.string.title_last_played)); - headerBinding.sortButtonIcon.setImageResource( - R.drawable.ic_filter_list); - headerBinding.sortButtonText.setText(R.string.title_most_played); - } - startLoading(true); - } - - private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { - return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); - } - - private void showInfoItemDialog(final StreamStatisticsEntry item) { - final Context context = getContext(); - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // set entries in the middle; the others are added automatically - dialogBuilder - .addEntry(StreamDialogDefaultEntry.DELETE) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteEntry( - Math.max(itemListAdapter.getItemsList().indexOf(item), 0))) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } - } - - private void deleteEntry(final int index) { - final LocalItem infoItem = itemListAdapter.getItemsList().get(index); - if (infoItem instanceof StreamStatisticsEntry) { - final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; - final Disposable onDelete = recordManager - .deleteStreamHistoryAndState(entry.getStreamId()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - () -> { - if (getView() != null) { - Snackbar.make(getView(), R.string.one_item_deleted, - Snackbar.LENGTH_SHORT).show(); - } else { - Toast.makeText(getContext(), - R.string.one_item_deleted, - Toast.LENGTH_SHORT).show(); - } - }, - throwable -> showSnackBarError(new ErrorInfo(throwable, - UserAction.DELETE_FROM_HISTORY, "Deleting item"))); - - disposables.add(onDelete); - } - } - - @Override - public PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - if (itemListAdapter == null) { - return new SinglePlayQueue(Collections.emptyList(), 0); - } - - final List infoItems = itemListAdapter.getItemsList(); - final List streamInfoItems = new ArrayList<>(infoItems.size()); - for (final LocalItem item : infoItems) { - if (item instanceof StreamStatisticsEntry) { - streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem()); - } - } - return new SinglePlayQueue(streamInfoItems, index); - } - - private enum StatisticSortMode { - LAST_PLAYED, - MOST_PLAYED, - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java deleted file mode 100644 index 16130009b..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -import java.time.format.DateTimeFormatter; - -public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder { - private final View itemHandleView; - - public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); - } - - LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - itemHandleView = itemView.findViewById(R.id.itemHandle); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistMetadataEntry)) { - return; - } - final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; - - itemHandleView.setOnTouchListener(getOnTouchListener(item)); - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } - - private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) { - return (view, motionEvent) -> { - view.performClick(); - if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null - && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - itemBuilder.getOnItemSelectedListener().drag(item, - LocalBookmarkPlaylistItemHolder.this); - } - return false; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java deleted file mode 100644 index a093d93e1..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -import java.time.format.DateTimeFormatter; - -/* - * Created by Christian Schabesberger on 12.02.17. - * - * Copyright (C) Christian Schabesberger 2016 - * InfoItemHolder.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public abstract class LocalItemHolder extends RecyclerView.ViewHolder { - protected final LocalItemBuilder itemBuilder; - - public LocalItemHolder(final LocalItemBuilder itemBuilder, final int layoutId, - final ViewGroup parent) { - super(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false)); - this.itemBuilder = itemBuilder; - } - - public abstract void updateFromItem(LocalItem item, HistoryRecordManager historyRecordManager, - DateTimeFormatter dateTimeFormatter); - - public void updateState(final LocalItem localItem, - final HistoryRecordManager historyRecordManager) { } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java deleted file mode 100644 index 33418ec98..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -/** - * Playlist card layout. - */ -public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder { - - public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java deleted file mode 100644 index 2b493f4ee..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -public class LocalPlaylistGridItemHolder extends LocalPlaylistItemHolder { - public LocalPlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java deleted file mode 100644 index 518fb3553..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.View; -import android.view.ViewGroup; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.time.format.DateTimeFormatter; - -public class LocalPlaylistItemHolder extends PlaylistItemHolder { - - private static final float GRAYED_OUT_ALPHA = 0.6f; - - public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, parent); - } - - LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistMetadataEntry item)) { - return; - } - - itemTitleView.setText(item.getOrderingName()); - itemStreamCountView.setText(Localization.localizeStreamCountMini( - itemStreamCountView.getContext(), item.getStreamCount())); - itemUploaderView.setVisibility(View.INVISIBLE); - - CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl()); - - if (item instanceof PlaylistDuplicatesEntry - && ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) { - itemView.setAlpha(GRAYED_OUT_ALPHA); - } else { - itemView.setAlpha(1.0f); - } - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java deleted file mode 100644 index 7f81a527f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -/** - * Local playlist stream UI. This also includes a handle to rearrange the videos. - */ -public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder { - - public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java deleted file mode 100644 index e2f936792..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -public class LocalPlaylistStreamGridItemHolder extends LocalPlaylistStreamItemHolder { - public LocalPlaylistStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent); // TODO - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java deleted file mode 100644 index 7dc71bfb4..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ /dev/null @@ -1,141 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.DependentPreferenceHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.views.AnimatedProgressBar; - -import java.time.format.DateTimeFormatter; -import java.util.concurrent.TimeUnit; - -public class LocalPlaylistStreamItemHolder extends LocalItemHolder { - public final ImageView itemThumbnailView; - public final TextView itemVideoTitleView; - private final TextView itemAdditionalDetailsView; - public final TextView itemDurationView; - private final View itemHandleView; - private final AnimatedProgressBar itemProgressView; - - LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); - itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); - itemDurationView = itemView.findViewById(R.id.itemDurationView); - itemHandleView = itemView.findViewById(R.id.itemHandle); - itemProgressView = itemView.findViewById(R.id.itemProgressView); - } - - public LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_stream_playlist_item, parent); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistStreamEntry)) { - return; - } - final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - - itemVideoTitleView.setText(item.getStreamEntity().getTitle()); - itemAdditionalDetailsView.setText(Localization - .concatenateStrings(item.getStreamEntity().getUploader(), - ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId()))); - - if (item.getStreamEntity().getDuration() > 0) { - itemDurationView.setText(Localization - .getDurationString(item.getStreamEntity().getDuration())); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), - R.color.duration_background_color)); - itemDurationView.setVisibility(View.VISIBLE); - - if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) - && item.getProgressMillis() > 0) { - itemProgressView.setVisibility(View.VISIBLE); - itemProgressView.setMax((int) item.getStreamEntity().getDuration()); - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - } else { - itemProgressView.setVisibility(View.GONE); - } - } else { - itemDurationView.setVisibility(View.GONE); - } - - // Default thumbnail is shown on error, while loading and if the url is empty - CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, - item.getStreamEntity().getThumbnailUrl()); - - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().selected(item); - } - }); - - itemView.setLongClickable(true); - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().held(item); - } - return true; - }); - - itemHandleView.setOnTouchListener(getOnTouchListener(item)); - } - - @Override - public void updateState(final LocalItem localItem, - final HistoryRecordManager historyRecordManager) { - if (!(localItem instanceof PlaylistStreamEntry)) { - return; - } - final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - - if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) - && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { - itemProgressView.setMax((int) item.getStreamEntity().getDuration()); - if (itemProgressView.getVisibility() == View.VISIBLE) { - itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - } else { - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - ViewUtils.animate(itemProgressView, true, 500); - } - } else if (itemProgressView.getVisibility() == View.VISIBLE) { - ViewUtils.animate(itemProgressView, false, 500); - } - } - - private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { - return (view, motionEvent) -> { - view.performClick(); - if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null - && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - itemBuilder.getOnItemSelectedListener().drag(item, - LocalPlaylistStreamItemHolder.this); - } - return false; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java deleted file mode 100644 index 4e03d5fb1..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder { - public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java deleted file mode 100644 index 39a43b034..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder { - public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_stream_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java deleted file mode 100644 index f26a76ad9..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ /dev/null @@ -1,161 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.DependentPreferenceHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.views.AnimatedProgressBar; - -import java.time.format.DateTimeFormatter; -import java.util.concurrent.TimeUnit; - -/* - * Created by Christian Schabesberger on 01.08.16. - *

- * Copyright (C) Christian Schabesberger 2016 - * StreamInfoItemHolder.java is part of NewPipe. - *

- * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class LocalStatisticStreamItemHolder extends LocalItemHolder { - public final ImageView itemThumbnailView; - public final TextView itemVideoTitleView; - public final TextView itemUploaderView; - public final TextView itemDurationView; - @Nullable - public final TextView itemAdditionalDetails; - private final AnimatedProgressBar itemProgressView; - - public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder, - final ViewGroup parent) { - this(itemBuilder, R.layout.list_stream_item, parent); - } - - LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); - itemUploaderView = itemView.findViewById(R.id.itemUploaderView); - itemDurationView = itemView.findViewById(R.id.itemDurationView); - itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); - itemProgressView = itemView.findViewById(R.id.itemProgressView); - } - - private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, - final DateTimeFormatter dateTimeFormatter) { - return Localization.concatenateStrings( - // watchCount - Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), - dateTimeFormatter.format(entry.getLatestAccessDate()), - // serviceName - ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof StreamStatisticsEntry)) { - return; - } - final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - - itemVideoTitleView.setText(item.getStreamEntity().getTitle()); - itemUploaderView.setText(item.getStreamEntity().getUploader()); - - if (item.getStreamEntity().getDuration() > 0) { - itemDurationView. - setText(Localization.getDurationString(item.getStreamEntity().getDuration())); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), - R.color.duration_background_color)); - itemDurationView.setVisibility(View.VISIBLE); - - if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) - && item.getProgressMillis() > 0) { - itemProgressView.setVisibility(View.VISIBLE); - itemProgressView.setMax((int) item.getStreamEntity().getDuration()); - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - } else { - itemProgressView.setVisibility(View.GONE); - } - } else { - itemDurationView.setVisibility(View.GONE); - itemProgressView.setVisibility(View.GONE); - } - - if (itemAdditionalDetails != null) { - itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateTimeFormatter)); - } - - // Default thumbnail is shown on error, while loading and if the url is empty - CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, - item.getStreamEntity().getThumbnailUrl()); - - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().selected(item); - } - }); - - itemView.setLongClickable(true); - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().held(item); - } - return true; - }); - } - - @Override - public void updateState(final LocalItem localItem, - final HistoryRecordManager historyRecordManager) { - if (!(localItem instanceof StreamStatisticsEntry)) { - return; - } - final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - - if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) - && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { - itemProgressView.setMax((int) item.getStreamEntity().getDuration()); - if (itemProgressView.getVisibility() == View.VISIBLE) { - itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - } else { - itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressMillis())); - ViewUtils.animate(itemProgressView, true, 500); - } - } else if (itemProgressView.getVisibility() == View.VISIBLE) { - ViewUtils.animate(itemProgressView, false, 500); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java deleted file mode 100644 index e8c53161e..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -import java.time.format.DateTimeFormatter; - -public abstract class PlaylistItemHolder extends LocalItemHolder { - public final ImageView itemThumbnailView; - final TextView itemStreamCountView; - public final TextView itemTitleView; - public final TextView itemUploaderView; - - public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); - itemUploaderView = itemView.findViewById(R.id.itemUploaderView); - } - - public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - itemView.setOnClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().selected(localItem); - } - }); - - itemView.setLongClickable(true); - itemView.setOnLongClickListener(view -> { - if (itemBuilder.getOnItemSelectedListener() != null) { - itemBuilder.getOnItemSelectedListener().held(localItem); - } - return true; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java deleted file mode 100644 index 6d61d1e08..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -import java.time.format.DateTimeFormatter; - -public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder { - private final View itemHandleView; - - public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); - } - - RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - itemHandleView = itemView.findViewById(R.id.itemHandle); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistRemoteEntity)) { - return; - } - final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; - - itemHandleView.setOnTouchListener(getOnTouchListener(item)); - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } - - private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) { - return (view, motionEvent) -> { - view.performClick(); - if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null - && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - itemBuilder.getOnItemSelectedListener().drag(item, - RemoteBookmarkPlaylistItemHolder.this); - } - return false; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java deleted file mode 100644 index 74a67c3db..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -/** - * Playlist card UI for list item. - */ -public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder { - - public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_card_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java deleted file mode 100644 index 00dcefbda..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.LocalItemBuilder; - -public class RemotePlaylistGridItemHolder extends RemotePlaylistItemHolder { - public RemotePlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java deleted file mode 100644 index 1eb97a59e..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.schabi.newpipe.local.holder; - -import android.text.TextUtils; -import android.view.ViewGroup; - -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.local.LocalItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.time.format.DateTimeFormatter; - -public class RemotePlaylistItemHolder extends PlaylistItemHolder { - - public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, parent); - } - - RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - } - - @Override - public void updateFromItem(final LocalItem localItem, - final HistoryRecordManager historyRecordManager, - final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistRemoteEntity item)) { - return; - } - - itemTitleView.setText(item.getOrderingName()); - itemStreamCountView.setText(Localization.localizeStreamCountMini( - itemStreamCountView.getContext(), item.getStreamCount())); - // Here is where the uploader name is set in the bookmarked playlists library - if (!TextUtils.isEmpty(item.getUploader())) { - itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), - ServiceHelper.getNameOfServiceById(item.getServiceId()))); - } else { - itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId())); - } - - CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl()); - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/ExportPlaylist.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/ExportPlaylist.kt deleted file mode 100644 index a6c3561ec..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/ExportPlaylist.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.local.playlist - -import android.content.Context -import org.schabi.newpipe.R -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry -import org.schabi.newpipe.extractor.exceptions.ParsingException -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory -import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS -import org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES -import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST - -fun export( - shareMode: PlayListShareMode, - playlist: List, - context: Context -): String { - return when (shareMode) { - WITH_TITLES -> exportWithTitles(playlist, context) - JUST_URLS -> exportJustUrls(playlist) - YOUTUBE_TEMP_PLAYLIST -> exportAsYoutubeTempPlaylist(playlist) - } -} - -private fun exportWithTitles(playlist: List, context: Context): String { - return playlist.asSequence() - .map { it.streamEntity } - .map { entity -> - context.getString( - R.string.video_details_list_item, - entity.title, - entity.url - ) - } - .joinToString(separator = "\n") -} - -private fun exportJustUrls(playlist: List): String { - return playlist.joinToString(separator = "\n") { it.streamEntity.url } -} - -private fun exportAsYoutubeTempPlaylist(playlist: List): String { - val videoIDs = playlist.asReversed().asSequence() - .mapNotNull { getYouTubeId(it.streamEntity.url) } - .take(50) // YouTube limitation: temp playlists can't have more than 50 items - .toList() - .asReversed() - .joinToString(separator = ",") - - return "https://www.youtube.com/watch_videos?video_ids=$videoIDs" -} - -private val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance() - -/** - * Gets the video id from a YouTube URL. - * - * @param url YouTube URL - * @return the video id - */ -private fun getYouTubeId(url: String): String? { - return runCatching { linkHandler.getId(url) }.getOrNull() -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java deleted file mode 100644 index cb38d9bae..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ /dev/null @@ -1,925 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; -import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; -import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export; -import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS; -import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES; -import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST; -import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; - - -import android.content.Context; -import android.os.Bundle; -import android.os.Parcelable; -import android.text.InputType; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; -import android.widget.LinearLayout.LayoutParams; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import com.evernote.android.state.State; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.DialogEditTextBinding; -import org.schabi.newpipe.databinding.LocalPlaylistHeaderBinding; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.MainFragment; -import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; -import org.schabi.newpipe.local.BaseLocalListFragment; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class LocalPlaylistFragment extends BaseLocalListFragment, Void> - implements PlaylistControlViewHolder, DebounceSavable { - - private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; - @State - protected Long playlistId; - @State - protected String name; - @State - Parcelable itemsListState; - - private LocalPlaylistHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - private ItemTouchHelper itemTouchHelper; - - private LocalPlaylistManager playlistManager; - private Subscription databaseSubscription; - - private CompositeDisposable disposables; - - /** Whether the playlist has been fully loaded from db. */ - private AtomicBoolean isLoadingComplete; - /** Used to debounce saving playlist edits to disk. */ - private DebounceSaver debounceSaver; - /** Flag to prevent simultaneous rewrites of the playlist. */ - private boolean isRewritingPlaylist = false; - - /** - * The pager adapter that the fragment is created from when it is used as frontpage, i.e. - * {@link #useAsFrontPage} is {@link true}. - */ - @Nullable - private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null; - - public static LocalPlaylistFragment getInstance(final long playlistId, final String name) { - final var instance = new LocalPlaylistFragment(); - instance.setInitialData(playlistId, name); - return instance; - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - Creation - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); - - disposables = new CompositeDisposable(); - - isLoadingComplete = new AtomicBoolean(); - debounceSaver = new DebounceSaver(this); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Lifecycle - Views - /////////////////////////////////////////////////////////////////////////// - - @Override - public void setTitle(final String title) { - super.setTitle(title); - - if (headerBinding != null) { - headerBinding.playlistTitleView.setText(title); - } - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - setTitle(name); - } - - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = LocalPlaylistHeaderBinding.inflate(activity.getLayoutInflater(), itemsList, - false); - playlistControlBinding = headerBinding.playlistControl; - - headerBinding.playlistTitleView.setSelected(true); - - return headerBinding::getRoot; - } - - @Override - protected void initListeners() { - super.initListeners(); - - headerBinding.playlistTitleView.setOnClickListener(view -> createRenameDialog()); - - itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(itemsList); - - itemListAdapter.setSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final LocalItem selectedItem) { - if (selectedItem instanceof PlaylistStreamEntry entry) { - final StreamEntity item = entry.getStreamEntity(); - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - item.getServiceId(), item.getUrl(), item.getTitle(), null, false); - } - } - - @Override - public void held(final LocalItem selectedItem) { - if (selectedItem instanceof PlaylistStreamEntry) { - showInfoItemDialog((PlaylistStreamEntry) selectedItem); - } - } - - @Override - public void drag(final LocalItem selectedItem, - final RecyclerView.ViewHolder viewHolder) { - if (itemTouchHelper != null) { - itemTouchHelper.startDrag(viewHolder); - } - } - }); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Lifecycle - Loading - /////////////////////////////////////////////////////////////////////////// - - @Override - public void showLoading() { - super.showLoading(); - if (headerBinding != null) { - animate(headerBinding.getRoot(), false, 200); - animate(playlistControlBinding.getRoot(), false, 200); - } - } - - @Override - public void hideLoading() { - super.hideLoading(); - if (headerBinding != null) { - animate(headerBinding.getRoot(), true, 200); - animate(playlistControlBinding.getRoot(), true, 200); - } - } - - @Override - public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - if (disposables != null) { - disposables.clear(); - } - - if (debounceSaver != null) { - disposables.add(debounceSaver.getDebouncedSaver()); - debounceSaver.setNoChangesToSave(); - } - - isLoadingComplete.set(false); - - playlistManager.getPlaylistStreams(playlistId) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getPlaylistObserver()); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Lifecycle - Destruction - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onPause() { - super.onPause(); - itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); - - // Save on exit - saveImmediate(); - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_local_playlist, menu); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - if (itemListAdapter != null) { - itemListAdapter.unsetSelectedListener(); - } - - headerBinding = null; - playlistControlBinding = null; - - - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - if (disposables != null) { - disposables.clear(); - } - - databaseSubscription = null; - itemTouchHelper = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (debounceSaver != null) { - debounceSaver.getDebouncedSaveSignal().onComplete(); - } - if (disposables != null) { - disposables.dispose(); - } - if (tabsPagerAdapter != null) { - tabsPagerAdapter.getLocalPlaylistFragments().remove(this); - } - - debounceSaver = null; - playlistManager = null; - disposables = null; - - isLoadingComplete = null; - } - - /////////////////////////////////////////////////////////////////////////// - // Playlist Stream Loader - /////////////////////////////////////////////////////////////////////////// - - private Subscriber> getPlaylistObserver() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - showLoading(); - isLoadingComplete.set(false); - - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - databaseSubscription = s; - databaseSubscription.request(1); - } - - @Override - public void onNext(final List streams) { - // Skip handling the result after it has been modified - if (debounceSaver == null || !debounceSaver.getIsModified()) { - handleResult(streams); - isLoadingComplete.set(true); - } - - if (databaseSubscription != null) { - databaseSubscription.request(1); - } - } - - @Override - public void onError(final Throwable exception) { - showError(new ErrorInfo(exception, UserAction.REQUESTED_BOOKMARK, - "Loading local playlist")); - } - - @Override - public void onComplete() { - } - }; - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == R.id.menu_item_share_playlist) { - createShareConfirmationDialog(); - } else if (item.getItemId() == R.id.menu_item_rename_playlist) { - createRenameDialog(); - } else if (item.getItemId() == R.id.menu_item_remove_watched) { - if (!isRewritingPlaylist) { - openRemoveWatchedConfirmationDialog(); - } - } else if (item.getItemId() == R.id.menu_item_remove_duplicates) { - if (!isRewritingPlaylist) { - openRemoveDuplicatesDialog(); - } - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - /** - * Shares the playlist in one of 3 ways, depending on the value of {@code shareMode}: - *

    - *
  • {@code JUST_URLS}: shares the URLs only.
  • - *
  • {@code WITH_TITLES}: each entry in the list is accompanied by its title.
  • - *
  • {@code YOUTUBE_TEMP_PLAYLIST}: shares as a YouTube temporary playlist.
  • - *
- * - * @param shareMode The way the playlist should be shared. - */ - private void sharePlaylist(final PlayListShareMode shareMode) { - final Context context = requireContext(); - - disposables.add(playlistManager.getPlaylistStreams(playlistId) - .flatMapSingle(playlist -> Single.just(export( - - shareMode, - playlist, - context - ))) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - urlsText -> { - - final String content = shareMode == WITH_TITLES - ? context.getString(R.string.share_playlist_content_details, - name, - urlsText - ) - : urlsText; - - ShareUtils.shareText(context, name, content); - }, - throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable) - ) - ); - } - - public void removeWatchedStreams(final boolean removePartiallyWatched) { - if (isRewritingPlaylist) { - return; - } - isRewritingPlaylist = true; - showLoading(); - - final var recordManager = new HistoryRecordManager(getContext()); - final var historyIdsMaybe = recordManager.getStreamHistorySortedById() - .firstElement() - // already sorted by ^ getStreamHistorySortedById(), binary search can be used - .map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId) - .collect(Collectors.toList())); - final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId) - .firstElement() - .zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> { - // Remove Watched, Functionality data - final List itemsToKeep = new ArrayList<>(); - final boolean isThumbnailPermanent = playlistManager - .getIsPlaylistThumbnailPermanent(playlistId); - boolean thumbnailVideoRemoved = false; - - final var streamStates = recordManager - .loadLocalStreamStateBatch(playlist).blockingGet(); - - for (int i = 0; i < playlist.size(); i++) { - final var playlistItem = playlist.get(i); - final var streamStateEntity = streamStates.get(i); - final int indexInHistory = Collections.binarySearch(historyStreamIds, - playlistItem.getStreamId()); - final long duration = playlistItem.toStreamInfoItem().getDuration(); - - if (indexInHistory < 0 // stream is not in history - // stream is in history but the streamStateEntity is null - // if the stream was played for less than 5 seconds, see - // StreamStateEntity#PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS - || streamStateEntity == null - || (!removePartiallyWatched - && !streamStateEntity.isFinished(duration))) { - itemsToKeep.add(playlistItem); - } else if (!isThumbnailPermanent && !thumbnailVideoRemoved - && playlistManager.getPlaylistThumbnailStreamId(playlistId) - == playlistItem.getStreamEntity().getUid()) { - thumbnailVideoRemoved = true; - } - } - - return new Pair<>(itemsToKeep, thumbnailVideoRemoved); - }); - - disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(flow -> { - final List itemsToKeep = flow.first; - final boolean thumbnailVideoRemoved = flow.second; - - itemListAdapter.clearStreamItemList(); - itemListAdapter.addItems(itemsToKeep); - debounceSaver.setHasChangesToSave(); - saveImmediate(); - - if (thumbnailVideoRemoved) { - updateThumbnailUrl(); - } - - final long videoCount = itemListAdapter.getItemsList().size(); - setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); - if (videoCount == 0) { - showEmptyState(); - } - - hideLoading(); - isRewritingPlaylist = false; - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Removing watched videos, partially watched=" + removePartiallyWatched)))); - } - - @Override - public void handleResult(@NonNull final List result) { - super.handleResult(result); - if (itemListAdapter == null) { - return; - } - - itemListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - showEmptyState(); - return; - } - - itemListAdapter.addItems(result); - if (itemsListState != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); - - PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); - - hideLoading(); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - /////////////////////////////////////////////////////////////////////////// - - @Override - protected void resetFragment() { - super.resetFragment(); - if (databaseSubscription != null) { - databaseSubscription.cancel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist Metadata/Streams Manipulation - //////////////////////////////////////////////////////////////////////////*/ - - private void createRenameDialog() { - if (playlistId == null || name == null || getContext() == null) { - return; - } - - final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); - dialogBinding.dialogEditText.setHint(R.string.name); - dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length()); - dialogBinding.dialogEditText.setText(name); - - new AlertDialog.Builder(getContext()) - .setTitle(R.string.rename_playlist) - .setView(dialogBinding.getRoot()) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.rename, (dialogInterface, i) -> - changePlaylistName(dialogBinding.dialogEditText.getText().toString())) - .show(); - } - - private void changePlaylistName(final String title) { - if (playlistManager == null) { - return; - } - - this.name = title; - setTitle(title); - - if (DEBUG) { - Log.d(TAG, "Updating playlist id=[" + playlistId + "] " - + "with new title=[" + title + "] items"); - } - - final Disposable disposable = playlistManager.renamePlaylist(playlistId, title) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Renaming playlist"))); - disposables.add(disposable); - } - - private void changeThumbnailStreamId(final long thumbnailStreamId, final boolean isPermanent) { - if (playlistManager == null || (!isPermanent && playlistManager - .getIsPlaylistThumbnailPermanent(playlistId))) { - return; - } - - final Toast successToast = Toast.makeText(getActivity(), - R.string.playlist_thumbnail_change_success, - Toast.LENGTH_SHORT); - - if (DEBUG) { - Log.d(TAG, "Updating playlist id=[" + playlistId + "] " - + "with new thumbnail stream id=[" + thumbnailStreamId + "]"); - } - - final Disposable disposable = playlistManager - .changePlaylistThumbnail(playlistId, thumbnailStreamId, isPermanent) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignore -> successToast.show(), throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Changing playlist thumbnail"))); - disposables.add(disposable); - } - - private void updateThumbnailUrl() { - if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) { - return; - } - - final long thumbnailStreamId; - - if (!itemListAdapter.getItemsList().isEmpty()) { - thumbnailStreamId = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)) - .getStreamEntity().getUid(); - } else { - thumbnailStreamId = PlaylistEntity.DEFAULT_THUMBNAIL_ID; - } - - changeThumbnailStreamId(thumbnailStreamId, false); - } - - private void openRemoveDuplicatesDialog() { - new AlertDialog.Builder(this.getActivity()) - .setTitle(R.string.remove_duplicates_title) - .setMessage(R.string.remove_duplicates_message) - .setPositiveButton(R.string.ok, (dialog, i) -> - removeDuplicatesInPlaylist()) - .setNeutralButton(R.string.cancel, null) - .show(); - } - - private void removeDuplicatesInPlaylist() { - if (isRewritingPlaylist) { - return; - } - isRewritingPlaylist = true; - showLoading(); - - final var streamsMaybe = playlistManager - .getDistinctPlaylistStreams(playlistId).firstElement(); - - - disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(itemsToKeep -> { - itemListAdapter.clearStreamItemList(); - itemListAdapter.addItems(itemsToKeep); - setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); - debounceSaver.setHasChangesToSave(); - saveImmediate(); - - hideLoading(); - isRewritingPlaylist = false; - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Removing duplicated streams")))); - } - - private void deleteItem(final PlaylistStreamEntry item) { - if (itemListAdapter == null) { - return; - } - - itemListAdapter.removeItem(item); - if (playlistManager.getPlaylistThumbnailStreamId(playlistId) == item.getStreamId()) { - updateThumbnailUrl(); - } - - setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); - debounceSaver.setHasChangesToSave(); - saveImmediate(); - } - - /** - *

Commit changes immediately if the playlist has been modified.

- * Delete operations and other modifications will be committed to ensure that the database - * is up to date, e.g. when the user adds the just deleted stream from another fragment. - */ - @Override - public void saveImmediate() { - if (playlistManager == null || itemListAdapter == null) { - return; - } - - // List must be loaded and modified in order to save - if (isLoadingComplete == null || debounceSaver == null - || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { - return; - } - - final List items = itemListAdapter.getItemsList(); - final List streamIds = new ArrayList<>(items.size()); - for (final LocalItem item : items) { - if (item instanceof PlaylistStreamEntry entry) { - streamIds.add(entry.getStreamId()); - } - } - - if (DEBUG) { - Log.d(TAG, "Updating playlist id=[" + playlistId + "] " - + "with [" + streamIds.size() + "] items"); - } - - final Disposable disposable = playlistManager.updateJoin(playlistId, streamIds) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - () -> { - if (debounceSaver != null) { - debounceSaver.setNoChangesToSave(); - } - }, - throwable -> showError(new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, "Saving playlist")) - ); - disposables.add(disposable); - } - - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; - if (shouldUseGridLayout(requireContext())) { - directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; - } - return new ItemTouchHelper.SimpleCallback(directions, - ItemTouchHelper.ACTION_STATE_IDLE) { - @Override - public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, - final int viewSize, - final int viewSizeOutOfBounds, - final int totalSize, - final long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, - viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, - Math.abs(standardSpeed)); - return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder source, - @NonNull final RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType() - || itemListAdapter == null) { - return false; - } - - final int sourceIndex = source.getBindingAdapterPosition(); - final int targetIndex = target.getBindingAdapterPosition(); - final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); - if (isSwapped) { - debounceSaver.setHasChangesToSave(); - } - return isSwapped; - } - - @Override - public void clearView(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder viewHolder) { - super.clearView(recyclerView, viewHolder); - saveImmediate(); - } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, - final int swipeDir) { - } - }; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) { - return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); - } - - protected void showInfoItemDialog(final PlaylistStreamEntry item) { - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final Context context = getContext(); - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // add entries in the middle - dialogBuilder.addAllEntries( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - StreamDialogDefaultEntry.DELETE - ); - - // set custom actions - // all entries modified below have already been added within the builder - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, i) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(item), true)) - .setAction( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - (f, i) -> - changeThumbnailStreamId(item.getStreamEntity().getUid(), - true)) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteItem(item)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } - } - - private void setInitialData(final long pid, final String title) { - this.playlistId = pid; - this.name = !TextUtils.isEmpty(title) ? title : ""; - } - - private void setStreamCountAndOverallDuration(final ArrayList itemsList) { - if (activity != null && headerBinding != null) { - final long streamCount = itemsList.size(); - final long playlistOverallDurationSeconds = itemsList.stream() - .filter(PlaylistStreamEntry.class::isInstance) - .map(PlaylistStreamEntry.class::cast) - .map(PlaylistStreamEntry::getStreamEntity) - .mapToLong(StreamEntity::getDuration) - .sum(); - headerBinding.playlistStreamCount.setText( - Localization.concatenateStrings( - Localization.localizeStreamCount(activity, streamCount), - Localization.getDurationString(playlistOverallDurationSeconds, - true, true)) - ); - } - } - - @Override - public PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - if (itemListAdapter == null) { - return new SinglePlayQueue(Collections.emptyList(), 0); - } - - final List infoItems = itemListAdapter.getItemsList(); - final List streamInfoItems = new ArrayList<>(infoItems.size()); - for (final LocalItem item : infoItems) { - if (item instanceof PlaylistStreamEntry) { - streamInfoItems.add(((PlaylistStreamEntry) item).toStreamInfoItem()); - } - } - return new SinglePlayQueue(streamInfoItems, index); - } - - /** - * Creates a dialog to confirm whether the user wants to share the playlist - * with the playlist details or just the list of stream URLs. - * After the user has made a choice, the playlist is shared. - */ - private void createShareConfirmationDialog() { - new AlertDialog.Builder(requireContext()) - .setTitle(R.string.share_playlist) - .setCancelable(true) - .setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) -> - sharePlaylist(WITH_TITLES) - ) - .setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist, - (dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST) - ) - .setNegativeButton(R.string.share_playlist_with_list, (dialog, which) -> - sharePlaylist(JUST_URLS) - ) - .show(); - } - - /** - * Opens a confirmation dialog to remove watched streams from the playlist. - * The user can also choose to remove partially watched streams. - */ - private void openRemoveWatchedConfirmationDialog() { - final android.widget.CheckBox removePartiallyWatchedCheckbox = - new android.widget.CheckBox(requireContext()); - removePartiallyWatchedCheckbox.setText( - R.string.remove_watched_popup_partially_watched_streams); - - // Wrap the checkbox in a container with dialog-like horizontal padding - // so it aligns with the dialog title and message on the start side. - final LinearLayout checkboxContainer = new LinearLayout(requireContext()); - checkboxContainer.setOrientation(LinearLayout.VERTICAL); - final int padding = DeviceUtils.dpToPx(20, requireContext()); - checkboxContainer.setPadding(padding, padding, padding, 0); - checkboxContainer.addView(removePartiallyWatchedCheckbox, - new LayoutParams(MATCH_PARENT, WRAP_CONTENT)); - - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.remove_watched_popup_warning) - .setTitle(R.string.remove_watched_popup_title) - .setView(checkboxContainer) - .setPositiveButton(R.string.yes, (d, id) -> - removeWatchedStreams(removePartiallyWatchedCheckbox.isChecked())) - .setNegativeButton(R.string.cancel, (d, id) -> d.cancel()) - .show(); - } - - public void setTabsPagerAdapter( - @Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) { - this.tabsPagerAdapter = tabsPagerAdapter; - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java deleted file mode 100644 index 1480735fb..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; -import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.dao.StreamDAO; -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class LocalPlaylistManager { - private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2; - - private final AppDatabase database; - private final StreamDAO streamTable; - private final PlaylistDAO playlistTable; - private final PlaylistStreamDAO playlistStreamTable; - - public LocalPlaylistManager(final AppDatabase db) { - database = db; - streamTable = db.streamDAO(); - playlistTable = db.playlistDAO(); - playlistStreamTable = db.playlistStreamDAO(); - } - - public Maybe> createPlaylist(final String name, final List streams) { - // Disallow creation of empty playlists - if (streams.isEmpty()) { - return Maybe.empty(); - } - - // Save to the database directly. - // Make sure the new playlist is always on the top of bookmark. - // The index will be reassigned to non-negative number in BookmarkFragment. - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final List streamIds = streamTable.upsertAll(streams); - final PlaylistEntity newPlaylist = new PlaylistEntity(name, false, - streamIds.get(0), -1); - - return insertJoinEntities(playlistTable.insert(newPlaylist), - streamIds, 0); - } - )).subscribeOn(Schedulers.io()); - } - - public Maybe> appendToPlaylist(final long playlistId, - final List streams) { - return playlistStreamTable.getMaximumIndexOf(playlistId) - .firstElement() - .map(maxJoinIndex -> database.runInTransaction(() -> { - final List streamIds = streamTable.upsertAll(streams); - return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1); - } - )).subscribeOn(Schedulers.io()); - } - - private List insertJoinEntities(final long playlistId, final List streamIds, - final int indexOffset) { - - final List joinEntities = new ArrayList<>(streamIds.size()); - - for (int index = 0; index < streamIds.size(); index++) { - joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), - index + indexOffset)); - } - return playlistStreamTable.insertAll(joinEntities); - } - - public Completable updateJoin(final long playlistId, final List streamIds) { - final List joinEntities = new ArrayList<>(streamIds.size()); - for (int i = 0; i < streamIds.size(); i++) { - joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i)); - } - - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - playlistStreamTable.deleteBatch(playlistId); - playlistStreamTable.insertAll(joinEntities); - })).subscribeOn(Schedulers.io()); - } - - public Completable updatePlaylists(final List updateItems, - final List deletedItems) { - final List items = new ArrayList<>(updateItems.size()); - for (final PlaylistMetadataEntry item : updateItems) { - items.add(new PlaylistEntity(item)); - } - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - for (final Long uid : deletedItems) { - playlistTable.deletePlaylist(uid); - } - for (final PlaylistEntity item : items) { - playlistTable.upsertPlaylist(item); - } - })).subscribeOn(Schedulers.io()); - } - - public Flowable> getDistinctPlaylistStreams(final long playlistId) { - return playlistStreamTable - .getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io()); - } - - /** - * Get playlists with attached information about how many times the provided stream is already - * contained in each playlist. - * - * @param streamUrl the stream url for which to check for duplicates - * @return a list of {@link PlaylistDuplicatesEntry} - */ - public Flowable> getPlaylistDuplicates(final String streamUrl) { - return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylists() { - return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylistStreams(final long playlistId) { - return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); - } - - public Maybe renamePlaylist(final long playlistId, final String name) { - return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false); - } - - public Maybe changePlaylistThumbnail(final long playlistId, - final long thumbnailStreamId, - final boolean isPermanent) { - return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent); - } - - public long getPlaylistThumbnailStreamId(final long playlistId) { - return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId(); - } - - public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { - return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) - .isThumbnailPermanent(); - } - - public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { - final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) - .blockingFirst(); - if (streamId < 0) { - return PlaylistEntity.DEFAULT_THUMBNAIL_ID; - } - return streamId; - } - - private Maybe modifyPlaylist(final long playlistId, - @Nullable final String name, - final long thumbnailStreamId, - final boolean isPermanent) { - return playlistTable.getPlaylist(playlistId) - .firstElement() - .filter(playlistEntities -> !playlistEntities.isEmpty()) - .map(playlistEntities -> { - final PlaylistEntity playlist = playlistEntities.get(0); - if (name != null) { - playlist.setName(name); - } - if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { - playlist.setThumbnailStreamId(thumbnailStreamId); - playlist.setThumbnailPermanent(isPermanent); - } - return playlistTable.update(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Maybe hasPlaylists() { - return playlistTable.getCount() - .firstElement() - .map(count -> count > 0) - .subscribeOn(Schedulers.io()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt deleted file mode 100644 index 5595ce7fa..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.local.playlist - -enum class PlayListShareMode { - JUST_URLS, - WITH_TITLES, - YOUTUBE_TEMP_PLAYLIST -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt deleted file mode 100644 index 6961b6bb4..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.local.playlist - -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.database.AppDatabase -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity -import org.schabi.newpipe.extractor.playlist.PlaylistInfo - -class RemotePlaylistManager(private val database: AppDatabase) { - private val playlistRemoteTable = database.playlistRemoteDAO() - - val playlists: Flowable> - get() = playlistRemoteTable.playlists.subscribeOn(Schedulers.io()) - - fun getPlaylist(playlistId: Long): Flowable { - return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()) - } - - fun getPlaylist(info: PlaylistInfo): Flowable> { - return playlistRemoteTable.getPlaylist(info.serviceId.toLong(), info.url) - .subscribeOn(Schedulers.io()) - } - - fun deletePlaylist(playlistId: Long): Single { - return Single.fromCallable { playlistRemoteTable.deletePlaylist(playlistId) } - .subscribeOn(Schedulers.io()) - } - - fun updatePlaylists( - updateItems: List, - deletedItems: List - ): Completable { - return Completable.fromRunnable { - database.runInTransaction { - deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) } - updateItems.forEach { playlistRemoteTable.upsert(it) } - } - }.subscribeOn(Schedulers.io()) - } - - fun onBookmark(playlistInfo: PlaylistInfo): Single { - return Single.fromCallable { - val playlist = PlaylistRemoteEntity(playlistInfo) - playlistRemoteTable.upsert(playlist) - }.subscribeOn(Schedulers.io()) - } - - fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single { - return Single.fromCallable { - val playlist = PlaylistRemoteEntity(playlistInfo).apply { uid = playlistId } - playlistRemoteTable.update(playlist) - }.subscribeOn(Schedulers.io()) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt deleted file mode 100644 index 1fa70e4d8..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.schabi.newpipe.local.subscription - -import androidx.annotation.DrawableRes -import org.schabi.newpipe.R - -enum class FeedGroupIcon( - /** - * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB). - */ - val id: Int, - - /** - * The drawable resource. - */ - @DrawableRes val drawableResource: Int -) { - ALL(0, R.drawable.ic_asterisk), - MUSIC(1, R.drawable.ic_music_note), - EDUCATION(2, R.drawable.ic_school), - FITNESS(3, R.drawable.ic_fitness_center), - SPACE(4, R.drawable.ic_telescope), - COMPUTER(5, R.drawable.ic_computer), - GAMING(6, R.drawable.ic_videogame_asset), - SPORTS(7, R.drawable.ic_directions_bike), - NEWS(8, R.drawable.ic_campaign), - FAVORITES(9, R.drawable.ic_favorite), - CAR(10, R.drawable.ic_directions_car), - MOTORCYCLE(11, R.drawable.ic_motorcycle), - TREND(12, R.drawable.ic_trending_up), - MOVIE(13, R.drawable.ic_movie), - BACKUP(14, R.drawable.ic_backup), - ART(15, R.drawable.ic_palette), - PERSON(16, R.drawable.ic_person), - PEOPLE(17, R.drawable.ic_people), - MONEY(18, R.drawable.ic_attach_money), - KIDS(19, R.drawable.ic_child_care), - FOOD(20, R.drawable.ic_fastfood), - SMILE(21, R.drawable.ic_insert_emoticon), - EXPLORE(22, R.drawable.ic_explore), - RESTAURANT(23, R.drawable.ic_restaurant), - MIC(24, R.drawable.ic_mic), - HEADSET(25, R.drawable.ic_headset), - RADIO(26, R.drawable.ic_radio), - SHOPPING_CART(27, R.drawable.ic_shopping_cart), - WATCH_LATER(28, R.drawable.ic_watch_later), - WORK(29, R.drawable.ic_work), - HOT(30, R.drawable.ic_whatshot), - CHANNEL(31, R.drawable.ic_tv), - BOOKMARK(32, R.drawable.ic_bookmark), - PETS(33, R.drawable.ic_pets), - WORLD(34, R.drawable.ic_public), - STAR(35, R.drawable.ic_stars), - SUN(36, R.drawable.ic_wb_sunny), - RSS(37, R.drawable.ic_rss_feed), - WHATS_NEW(38, R.drawable.ic_subscriptions); - - @DrawableRes - fun getDrawableRes(): Int { - return drawableResource - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java deleted file mode 100644 index 3dc6d7b46..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.app.Dialog; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.core.os.BundleCompat; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.work.Constraints; -import androidx.work.ExistingWorkPolicy; -import androidx.work.NetworkType; -import androidx.work.OneTimeWorkRequest; -import androidx.work.OutOfQuotaPolicy; -import androidx.work.WorkManager; - -import com.livefront.bridge.Bridge; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; - -public class ImportConfirmationDialog extends DialogFragment { - private static final String INPUT = "input"; - - public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { - final var confirmationDialog = new ImportConfirmationDialog(); - final var arguments = new Bundle(); - arguments.putParcelable(INPUT, input); - confirmationDialog.setArguments(arguments); - confirmationDialog.show(fragment.getParentFragmentManager(), null); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - final var context = requireContext(); - return new AlertDialog.Builder(context) - .setMessage(R.string.import_network_expensive_warning) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialogInterface, i) -> { - final var constraints = new Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(); - final var input = BundleCompat.getParcelable(requireArguments(), INPUT, - SubscriptionImportInput.class); - - final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) - .setInputData(input.toData()) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .setConstraints(constraints) - .build(); - - WorkManager.getInstance(context) - .enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME, - ExistingWorkPolicy.APPEND_OR_REPLACE, req); - - dismiss(); - }) - .create(); - } - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - Bridge.restoreInstanceState(this, savedInstanceState); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt deleted file mode 100644 index 9a817362c..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ /dev/null @@ -1,421 +0,0 @@ -package org.schabi.newpipe.local.subscription - -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.SubMenu -import android.view.View -import android.view.ViewGroup -import android.webkit.MimeTypeMap -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.GridLayoutManager -import com.evernote.android.state.State -import com.xwray.groupie.Group -import com.xwray.groupie.GroupAdapter -import com.xwray.groupie.Section -import com.xwray.groupie.viewbinding.GroupieViewHolder -import io.reactivex.rxjava3.disposables.CompositeDisposable -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID -import org.schabi.newpipe.databinding.DialogTitleBinding -import org.schabi.newpipe.databinding.FeedItemCarouselBinding -import org.schabi.newpipe.databinding.FragmentSubscriptionBinding -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.fragments.BaseStateFragment -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog -import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog -import org.schabi.newpipe.local.subscription.item.ChannelItem -import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem -import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem -import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem -import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem -import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem -import org.schabi.newpipe.local.subscription.item.GroupsHeader -import org.schabi.newpipe.local.subscription.item.Header -import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem -import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.ServiceHelper -import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels -import org.schabi.newpipe.util.external_communication.ShareUtils - -class SubscriptionFragment : BaseStateFragment() { - private var _binding: FragmentSubscriptionBinding? = null - private val binding get() = _binding!! - - private lateinit var viewModel: SubscriptionViewModel - private lateinit var subscriptionManager: SubscriptionManager - private lateinit var importExportHelper: SubscriptionsImportExportHelper - private val disposables: CompositeDisposable = CompositeDisposable() - - private val groupAdapter = GroupAdapter>() - private lateinit var carouselAdapter: GroupAdapter> - private lateinit var feedGroupsCarousel: FeedGroupCarouselItem - private lateinit var feedGroupsSortMenuItem: GroupsHeader - private val subscriptionsSection = Section() - - @State - @JvmField - var itemsListState: Parcelable? = null - - @State - @JvmField - var feedGroupsCarouselState: Parcelable? = null - - init { - setHasOptionsMenu(true) - } - - // ///////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - // ///////////////////////////////////////////////////////////////////////// - - override fun onAttach(context: Context) { - super.onAttach(context) - subscriptionManager = SubscriptionManager(requireContext()) - importExportHelper = SubscriptionsImportExportHelper(this) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_subscription, container, false) - } - - override fun onPause() { - super.onPause() - itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState() - feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onDestroy() { - super.onDestroy() - disposables.dispose() - } - - // //////////////////////////////////////////////////////////////////////// - // Menu - // //////////////////////////////////////////////////////////////////////// - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - - activity.supportActionBar?.setDisplayShowTitleEnabled(true) - activity.supportActionBar?.setTitle(R.string.tab_subscriptions) - - buildImportExportMenu(menu) - } - - private fun buildImportExportMenu(menu: Menu) { - // -- Import -- - val importSubMenu = menu.addSubMenu(R.string.import_from) - - addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { importExportHelper.onImportPreviousSelected() } - .setIcon(R.drawable.ic_backup) - - for (service in ServiceList.all()) { - val subscriptionExtractor = service.subscriptionExtractor ?: continue - - val supportedSources = subscriptionExtractor.supportedSources - if (supportedSources.isEmpty()) continue - - addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) { - onImportFromServiceSelected(service.serviceId) - } - .setIcon(ServiceHelper.getIcon(service.serviceId)) - } - - // -- Export -- - val exportSubMenu = menu.addSubMenu(R.string.export_to) - - addMenuItemToSubmenu(exportSubMenu, R.string.file) { importExportHelper.onExportSelected() } - .setIcon(R.drawable.ic_save) - } - - private fun addMenuItemToSubmenu( - subMenu: SubMenu, - @StringRes title: Int, - onClick: Runnable - ): MenuItem { - return setClickListenerToMenuItem(subMenu.add(title), onClick) - } - - private fun addMenuItemToSubmenu( - subMenu: SubMenu, - title: String, - onClick: Runnable - ): MenuItem { - return setClickListenerToMenuItem(subMenu.add(title), onClick) - } - - private fun setClickListenerToMenuItem( - menuItem: MenuItem, - onClick: Runnable - ): MenuItem { - menuItem.setOnMenuItemClickListener { - onClick.run() - true - } - return menuItem - } - - private fun onImportFromServiceSelected(serviceId: Int) { - val fragmentManager = fm - NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId) - } - - private fun openReorderDialog() { - FeedGroupReorderDialog().show(parentFragmentManager, null) - } - - // //////////////////////////////////////////////////////////////////////// - // Fragment Views - // //////////////////////////////////////////////////////////////////////// - - override fun initViews(rootView: View, savedInstanceState: Bundle?) { - super.initViews(rootView, savedInstanceState) - _binding = FragmentSubscriptionBinding.bind(rootView) - - groupAdapter.spanCount = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) getGridSpanCountChannels(context) else 1 - binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { - spanSizeLookup = groupAdapter.spanSizeLookup - } - binding.itemsList.adapter = groupAdapter - binding.itemsList.itemAnimator = null - - viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java] - viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) } - viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { - it?.let { (groups, listViewMode) -> - handleFeedGroups(groups, listViewMode) - } - } - - setupInitialLayout() - } - - private fun setupInitialLayout() { - Section().apply { - carouselAdapter = GroupAdapter>() - - carouselAdapter.setOnItemClickListener { item, _ -> - when (item) { - is FeedGroupCardItem -> - NavigationHelper.openFeedFragment(fm, item.groupId, item.name) - - is FeedGroupCardGridItem -> - NavigationHelper.openFeedFragment(fm, item.groupId, item.name) - - is FeedGroupAddNewItem -> - FeedGroupDialog.newInstance().show(fm, null) - - is FeedGroupAddNewGridItem -> - FeedGroupDialog.newInstance().show(fm, null) - } - } - carouselAdapter.setOnItemLongClickListener { item, _ -> - if ((item is FeedGroupCardItem && item.groupId == GROUP_ALL_ID) || - (item is FeedGroupCardGridItem && item.groupId == GROUP_ALL_ID) - ) { - return@setOnItemLongClickListener false - } - - when (item) { - is FeedGroupCardItem -> - FeedGroupDialog.newInstance(item.groupId).show(fm, null) - - is FeedGroupCardGridItem -> - FeedGroupDialog.newInstance(item.groupId).show(fm, null) - } - return@setOnItemLongClickListener true - } - - feedGroupsCarousel = FeedGroupCarouselItem( - carouselAdapter = carouselAdapter, - listViewMode = viewModel.getListViewMode() - ) - - feedGroupsSortMenuItem = GroupsHeader( - title = getString(R.string.feed_groups_header_title), - onSortClicked = ::openReorderDialog, - onToggleListViewModeClicked = ::toggleListViewMode, - listViewMode = viewModel.getListViewMode() - ) - - add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) - groupAdapter.clear() - groupAdapter.add(this) - } - - subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem()) - subscriptionsSection.setHideWhenEmpty(true) - - groupAdapter.add( - Section( - Header(getString(R.string.tab_subscriptions)), - listOf(subscriptionsSection) - ) - ) - } - - private fun toggleListViewMode() { - viewModel.setListViewMode(!viewModel.getListViewMode()) - } - - private fun showLongTapDialog(selectedItem: ChannelInfoItem) { - val commands = arrayOf( - getString(R.string.share), - getString(R.string.open_in_browser), - getString(R.string.unsubscribe) - ) - - val actions = DialogInterface.OnClickListener { _, i -> - when (i) { - 0 -> ShareUtils.shareText( - requireContext(), - selectedItem.name, - selectedItem.url, - selectedItem.thumbnails - ) - - 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) - - 2 -> deleteChannel(selectedItem) - } - } - - val dialogTitleBinding = DialogTitleBinding.inflate(LayoutInflater.from(requireContext())) - dialogTitleBinding.root.isSelected = true - dialogTitleBinding.itemTitleView.text = selectedItem.name - dialogTitleBinding.itemAdditionalDetails.visibility = View.GONE - - AlertDialog.Builder(requireContext()) - .setCustomTitle(dialogTitleBinding.root) - .setItems(commands, actions) - .show() - } - - private fun deleteChannel(selectedItem: ChannelInfoItem) { - disposables.add( - subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { - Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() - } - ) - } - - override fun doInitialLoadLogic() = Unit - override fun startLoading(forceLoad: Boolean) = Unit - - private val listenerChannelItem = object : OnClickGesture { - override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment( - fm, - selectedItem.serviceId, - selectedItem.url, - selectedItem.name - ) - - override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem) - } - - override fun handleResult(result: SubscriptionState) { - super.handleResult(result) - - when (result) { - is SubscriptionState.LoadedState -> { - result.subscriptions.forEach { - if (it is ChannelItem) { - it.gesturesListener = listenerChannelItem - it.itemVersion = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) { - ChannelItem.ItemVersion.GRID - } else { - ChannelItem.ItemVersion.MINI - } - } - } - - subscriptionsSection.update(result.subscriptions) - subscriptionsSection.setHideWhenEmpty(false) - - if (itemsListState != null) { - binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState) - itemsListState = null - } - } - - is SubscriptionState.ErrorState -> { - result.error?.let { - showError(ErrorInfo(result.error, UserAction.SOMETHING_ELSE, "Subscriptions")) - } - } - } - } - - private fun handleFeedGroups(groups: List, listViewMode: Boolean) { - if (feedGroupsCarouselState != null) { - feedGroupsCarousel.onRestoreInstanceState(feedGroupsCarouselState) - feedGroupsCarouselState = null - } - - binding.itemsList.post { - if (context == null) { - // since this part was posted to the next UI cycle, the fragment might have been - // removed in the meantime - return@post - } - - feedGroupsCarousel.listViewMode = listViewMode - feedGroupsSortMenuItem.showSortButton = groups.size > 1 - feedGroupsSortMenuItem.listViewMode = listViewMode - feedGroupsCarousel.notifyChanged(FeedGroupCarouselItem.PAYLOAD_UPDATE_LIST_VIEW_MODE) - feedGroupsSortMenuItem.notifyChanged(GroupsHeader.PAYLOAD_UPDATE_ICONS) - - // update items here to prevent flickering - carouselAdapter.apply { - clear() - if (listViewMode) { - add(FeedGroupAddNewItem()) - add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) - } else { - add(FeedGroupAddNewGridItem()) - add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) - } - addAll(groups) - } - } - } - - // ///////////////////////////////////////////////////////////////////////// - // Contract - // ///////////////////////////////////////////////////////////////////////// - - override fun showLoading() { - super.showLoading() - binding.itemsList.animate(false, 100) - } - - override fun hideLoading() { - super.hideLoading() - binding.itemsList.animate(true, 200) - } - - companion object { - val JSON_MIME_TYPE = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension("json") ?: "application/octet-stream" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt deleted file mode 100644 index 5cf378cc3..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ /dev/null @@ -1,139 +0,0 @@ -package org.schabi.newpipe.local.subscription - -import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionDAO -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.local.feed.FeedDatabaseManager -import org.schabi.newpipe.local.feed.service.FeedUpdateInfo -import org.schabi.newpipe.util.ExtractorHelper -import org.schabi.newpipe.util.image.ImageStrategy - -class SubscriptionManager(context: Context) { - private val database = NewPipeDatabase.getInstance(context) - private val subscriptionTable = database.subscriptionDAO() - private val feedDatabaseManager = FeedDatabaseManager(context) - - fun subscriptionTable(): SubscriptionDAO = subscriptionTable - fun subscriptions() = subscriptionTable.getAll() - - fun getSubscriptions( - currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID, - filterQuery: String = "", - showOnlyUngrouped: Boolean = false - ): Flowable> { - return when { - filterQuery.isNotEmpty() -> { - return if (showOnlyUngrouped) { - subscriptionTable.getSubscriptionsOnlyUngroupedFiltered( - currentGroupId, - filterQuery - ) - } else { - subscriptionTable.getSubscriptionsFiltered(filterQuery) - } - } - - showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId) - - else -> subscriptionTable.getAll() - } - } - - fun upsertAll(infoList: List>) { - val listEntities = infoList.map { SubscriptionEntity.from(it.first) } - subscriptionTable.upsertAll(listEntities) - - database.runInTransaction { - infoList.forEachIndexed { index, info -> - val streams = info.second.relatedItems.filterIsInstance() - feedDatabaseManager.upsertAll(listEntities[index].uid, streams) - } - } - } - - fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) - .flatMapCompletable { - Completable.fromRunnable { - it.apply { - name = info.name - avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars) - description = info.description - subscriberCount = info.subscriberCount - } - subscriptionTable.update(it) - } - } - - fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { - return subscriptionTable().getSubscription(serviceId, url) - .flatMapCompletable { entity: SubscriptionEntity -> - Completable.fromAction { - entity.notificationMode = mode - subscriptionTable().update(entity) - }.apply { - if (mode != NotificationMode.DISABLED) { - // notifications have just been enabled, mark all streams as "old" - andThen(rememberAllStreams(entity)) - } - } - } - } - - fun updateFromInfo(info: FeedUpdateInfo) { - val subscriptionEntity = subscriptionTable.getSubscription(info.uid) - - subscriptionEntity.name = info.name - - // some services do not provide an avatar URL - info.avatarUrl?.let { subscriptionEntity.avatarUrl = it } - - // these two fields are null if the feed info was fetched using the fast feed method - info.description?.let { subscriptionEntity.description = it } - info.subscriberCount?.let { subscriptionEntity.subscriberCount = it } - - subscriptionTable.update(subscriptionEntity) - } - - fun deleteSubscription(serviceId: Int, url: String): Completable { - return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - - fun insertSubscription(subscriptionEntity: SubscriptionEntity) { - subscriptionTable.insert(subscriptionEntity) - } - - fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { - subscriptionTable.delete(subscriptionEntity) - } - - /** - * Fetches the list of videos for the provided channel and saves them in the database, so that - * they will be considered as "old"/"already seen" streams and the user will never be notified - * about any one of them. - */ - private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { - return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) - .flatMap { info -> - ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false) - } - .map { channel -> channel.relatedItems.filterIsInstance().map { stream -> StreamEntity(stream) } } - .flatMapCompletable { entities -> - Completable.fromAction { - database.streamDAO().upsertAll(entities) - } - }.onErrorComplete() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt deleted file mode 100644 index fc28f8e59..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt +++ /dev/null @@ -1,104 +0,0 @@ -package org.schabi.newpipe.local.subscription - -import android.app.Application -import android.content.Context -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.xwray.groupie.Group -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.processors.BehaviorProcessor -import io.reactivex.rxjava3.schedulers.Schedulers -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.info_list.ItemViewMode -import org.schabi.newpipe.local.feed.FeedDatabaseManager -import org.schabi.newpipe.local.subscription.item.ChannelItem -import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem -import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem -import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT -import org.schabi.newpipe.util.ThemeHelper.getItemViewMode - -class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { - private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) - private var subscriptionManager = SubscriptionManager(application) - - // true -> list view, false -> grid view - private val listViewMode = BehaviorProcessor.createDefault( - !shouldUseGridForSubscription(application) - ) - private val listViewModeFlowable = listViewMode.distinctUntilChanged() - - private val mutableStateLiveData = MutableLiveData() - private val mutableFeedGroupsLiveData = MutableLiveData, Boolean>>() - val stateLiveData: LiveData = mutableStateLiveData - val feedGroupsLiveData: LiveData, Boolean>> = mutableFeedGroupsLiveData - - private var feedGroupItemsDisposable = Flowable - .combineLatest( - feedDatabaseManager.groups(), - listViewModeFlowable, - ::Pair - ) - .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) - .map { (feedGroups, listViewMode) -> - Pair( - feedGroups.map(if (listViewMode) ::FeedGroupCardItem else ::FeedGroupCardGridItem), - listViewMode - ) - } - .subscribeOn(Schedulers.io()) - .subscribe( - { mutableFeedGroupsLiveData.postValue(it) }, - { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } - ) - - private var stateItemsDisposable = subscriptionManager.subscriptions() - .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) - .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } } - .subscribeOn(Schedulers.io()) - .subscribe( - { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) }, - { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } - ) - - override fun onCleared() { - super.onCleared() - stateItemsDisposable.dispose() - feedGroupItemsDisposable.dispose() - } - - fun setListViewMode(newListViewMode: Boolean) { - listViewMode.onNext(newListViewMode) - } - - fun getListViewMode(): Boolean { - return listViewMode.value ?: true - } - - sealed class SubscriptionState { - data class LoadedState(val subscriptions: List) : SubscriptionState() - data class ErrorState(val error: Throwable? = null) : SubscriptionState() - } - - companion object { - - /** - * Returns whether to use GridLayout mode for Subscription Fragment. - * - * ### Current mapping: - * - * | ItemViewMode | ItemVersion | Span count | - * |---|---|---| - * | AUTO | MINI | 1 | - * | LIST | MINI | 1 | - * | CARD | GRID | > 1 (ThemeHelper defined) | - * | GRID | GRID | > 1 (ThemeHelper defined) | - * - * @see [SubscriptionViewModel.shouldUseGridForSubscription] to modify Layout Manager - */ - fun shouldUseGridForSubscription(context: Context): Boolean { - val itemViewMode = getItemViewMode(context) - return itemViewMode == ItemViewMode.GRID || itemViewMode == ItemViewMode.CARD - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportExportHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportExportHelper.kt deleted file mode 100644 index b853dcd41..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportExportHelper.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.schabi.newpipe.local.subscription - -import android.app.Activity -import android.content.Context -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.fragment.app.Fragment -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import org.schabi.newpipe.local.subscription.SubscriptionFragment.Companion.JSON_MIME_TYPE -import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard -import org.schabi.newpipe.streams.io.StoredFileHelper - -/** - * This class has to be created in onAttach() or onCreate(). - * - * It contains registerForActivityResult calls and those - * calls are only allowed before a fragment/activity is created. - */ -class SubscriptionsImportExportHelper( - val fragment: Fragment -) { - val context: Context = fragment.requireContext() - - companion object { - val TAG: String = - SubscriptionsImportExportHelper::class.java.simpleName + "@" + Integer.toHexString( - hashCode() - ) - } - - private val requestExportLauncher = - fragment.registerForActivityResult(StartActivityForResult(), this::requestExportResult) - private val requestImportLauncher = - fragment.registerForActivityResult(StartActivityForResult(), this::requestImportResult) - - private fun requestExportResult(result: ActivityResult) { - val data = result.data?.data - if (data != null && result.resultCode == Activity.RESULT_OK) { - SubscriptionExportWorker.schedule(context, data) - } - } - - private fun requestImportResult(result: ActivityResult) { - val data = result.data?.dataString - if (data != null && result.resultCode == Activity.RESULT_OK) { - ImportConfirmationDialog.show( - fragment, - SubscriptionImportInput.PreviousExportMode(data) - ) - } - } - - fun onExportSelected() { - val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) - val exportName = "newpipe_subscriptions_$date.json" - - NoFileManagerSafeGuard.launchSafe( - requestExportLauncher, - StoredFileHelper.getNewPicker( - context, - exportName, - JSON_MIME_TYPE, - null - ), - TAG, - context - ) - } - - fun onImportPreviousSelected() { - NoFileManagerSafeGuard.launchSafe( - requestImportLauncher, - StoredFileHelper.getPicker(context, JSON_MIME_TYPE), - TAG, - context - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java deleted file mode 100644 index fbadbb876..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ /dev/null @@ -1,216 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.text.TextUtils; -import android.text.util.Linkify; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.ActionBar; -import androidx.core.text.util.LinkifyCompat; - -import com.evernote.android.state.State; - -import org.schabi.newpipe.BaseFragment; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ServiceHelper; - -import java.util.Collections; -import java.util.List; - -public class SubscriptionsImportFragment extends BaseFragment { - @State - int currentServiceId = Constants.NO_SERVICE_ID; - - private List supportedSources; - private String relatedUrl; - - @StringRes - private int instructionsString; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private TextView infoTextView; - private EditText inputText; - private Button inputButton; - - private final ActivityResultLauncher requestImportFileLauncher = - registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult); - - public static SubscriptionsImportFragment getInstance(final int serviceId) { - final SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); - instance.setInitialData(serviceId); - return instance; - } - - private void setInitialData(final int serviceId) { - this.currentServiceId = serviceId; - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setupServiceVariables(); - if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { - ErrorUtil.showSnackbar(activity, - new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, - "Service does not support importing subscriptions", - currentServiceId, - R.string.general_error)); - activity.finish(); - } - } - - @Override - public void onResume() { - super.onResume(); - setTitle(getString(R.string.import_title)); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_import, container, false); - } - - /*///////////////////////////////////////////////////////////////////////// - // Fragment Views - /////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - inputButton = rootView.findViewById(R.id.input_button); - inputText = rootView.findViewById(R.id.input_text); - - infoTextView = rootView.findViewById(R.id.info_text_view); - - // TODO: Support services that can import from more than one source - // (show the option to the user) - if (supportedSources.contains(CHANNEL_URL)) { - inputButton.setText(R.string.import_title); - inputText.setVisibility(View.VISIBLE); - inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId)); - } else { - inputButton.setText(R.string.import_file_title); - } - - if (instructionsString != 0) { - if (TextUtils.isEmpty(relatedUrl)) { - setInfoText(getString(instructionsString)); - } else { - setInfoText(getString(instructionsString, relatedUrl)); - } - } else { - setInfoText(""); - } - - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(true); - setTitle(getString(R.string.import_title)); - } - } - - @Override - protected void initListeners() { - super.initListeners(); - inputButton.setOnClickListener(v -> onImportClicked()); - } - - private void onImportClicked() { - if (inputText.getVisibility() == View.VISIBLE) { - final String value = inputText.getText().toString(); - if (!value.isEmpty()) { - onImportUrl(value); - } - } else { - onImportFile(); - } - } - - public void onImportUrl(final String value) { - ImportConfirmationDialog.show(this, - new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value)); - } - - public void onImportFile() { - NoFileManagerSafeGuard.launchSafe( - requestImportFileLauncher, - // leave */* mime type to support all services - // with different mime types and file extensions - StoredFileHelper.getPicker(activity, "*/*"), - TAG, - getContext() - ); - } - - private void requestImportFileResult(final ActivityResult result) { - final String data = result.getData() != null ? result.getData().getDataString() : null; - if (result.getResultCode() == Activity.RESULT_OK && data != null) { - ImportConfirmationDialog.show(this, - new SubscriptionImportInput.InputStreamMode(currentServiceId, data)); - } - } - - /////////////////////////////////////////////////////////////////////////// - // Subscriptions - /////////////////////////////////////////////////////////////////////////// - - private void setupServiceVariables() { - if (currentServiceId != Constants.NO_SERVICE_ID) { - try { - final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId) - .getSubscriptionExtractor(); - supportedSources = extractor.getSupportedSources(); - relatedUrl = extractor.getRelatedUrl(); - instructionsString = ServiceHelper.getImportInstructions(currentServiceId); - return; - } catch (final ExtractionException ignored) { - } - } - - supportedSources = Collections.emptyList(); - relatedUrl = null; - instructionsString = 0; - } - - private void setInfoText(final String infoString) { - infoTextView.setText(infoString); - LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt deleted file mode 100644 index b0ccaa3c5..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt +++ /dev/null @@ -1,550 +0,0 @@ -package org.schabi.newpipe.local.subscription.dialog - -import android.app.Dialog -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.core.content.getSystemService -import androidx.core.os.bundleOf -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.evernote.android.state.State -import com.livefront.bridge.Bridge -import com.xwray.groupie.GroupieAdapter -import com.xwray.groupie.OnItemClickListener -import com.xwray.groupie.Section -import java.io.Serializable -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.databinding.DialogFeedGroupCreateBinding -import org.schabi.newpipe.databinding.ToolbarSearchLayoutBinding -import org.schabi.newpipe.fragments.BackPressable -import org.schabi.newpipe.local.subscription.FeedGroupIcon -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.DeleteScreen -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.IconPickerScreen -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.InitialScreen -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent -import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent -import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem -import org.schabi.newpipe.local.subscription.item.PickerIconItem -import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem -import org.schabi.newpipe.util.DeviceUtils -import org.schabi.newpipe.util.ThemeHelper - -class FeedGroupDialog : DialogFragment(), BackPressable { - private var _feedGroupCreateBinding: DialogFeedGroupCreateBinding? = null - private val feedGroupCreateBinding get() = _feedGroupCreateBinding!! - - private var _searchLayoutBinding: ToolbarSearchLayoutBinding? = null - private val searchLayoutBinding get() = _searchLayoutBinding!! - - private lateinit var viewModel: FeedGroupDialogViewModel - private var groupId: Long = NO_GROUP_SELECTED - private var groupIcon: FeedGroupIcon? = null - private var groupSortOrder: Long = -1 - - sealed class ScreenState : Serializable { - data object InitialScreen : ScreenState() - data object IconPickerScreen : ScreenState() - data object SubscriptionsPickerScreen : ScreenState() - data object DeleteScreen : ScreenState() - } - - @State - @JvmField - var selectedIcon: FeedGroupIcon? = null - - @State - @JvmField - var selectedSubscriptions: HashSet = HashSet() - - @State - @JvmField - var wasSubscriptionSelectionChanged: Boolean = false - - @State - @JvmField - var currentScreen: ScreenState = InitialScreen - - @State - @JvmField - var subscriptionsListState: Parcelable? = null - - @State - @JvmField - var iconsListState: Parcelable? = null - - @State - @JvmField - var wasSearchSubscriptionsVisible = false - - @State - @JvmField - var subscriptionsCurrentSearchQuery = "" - - @State - @JvmField - var subscriptionsShowOnlyUngrouped = false - - private val subscriptionMainSection = Section() - private val subscriptionEmptyFooter = Section() - private lateinit var subscriptionGroupAdapter: GroupieAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - Bridge.restoreInstanceState(this, savedInstanceState) - - setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) - groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.dialog_feed_group_create, container) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return object : Dialog(requireActivity(), theme) { - override fun onBackPressed() { - if (!this@FeedGroupDialog.onBackPressed()) { - super.onBackPressed() - } - } - } - } - - override fun onPause() { - super.onPause() - - wasSearchSubscriptionsVisible = isSearchVisible() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - iconsListState = feedGroupCreateBinding.iconSelector.layoutManager?.onSaveInstanceState() - subscriptionsListState = feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onSaveInstanceState() - - Bridge.saveInstanceState(this, outState) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view) - _searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer - - viewModel = ViewModelProvider( - this, - FeedGroupDialogViewModel.getFactory( - requireContext(), - groupId, - subscriptionsCurrentSearchQuery, - subscriptionsShowOnlyUngrouped - ) - )[FeedGroupDialogViewModel::class.java] - - viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) - viewModel.subscriptionsLiveData.observe(viewLifecycleOwner) { - setupSubscriptionPicker(it.first, it.second) - } - viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { - when (it) { - ProcessingEvent -> disableInput() - SuccessEvent -> dismiss() - } - } - - subscriptionGroupAdapter = GroupieAdapter().apply { - add(subscriptionMainSection) - add(subscriptionEmptyFooter) - spanCount = 4 - } - feedGroupCreateBinding.subscriptionsSelectorList.apply { - // Disable animations, too distracting. - itemAnimator = null - adapter = subscriptionGroupAdapter - layoutManager = GridLayoutManager( - requireContext(), - subscriptionGroupAdapter.spanCount, - RecyclerView.VERTICAL, - false - ).apply { - spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup - } - } - - setupIconPicker() - setupListeners() - - showScreen(currentScreen) - - if (currentScreen == SubscriptionsPickerScreen && wasSearchSubscriptionsVisible) { - showSearch() - } else if (currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED) { - showKeyboard() - } - } - - override fun onDestroyView() { - super.onDestroyView() - feedGroupCreateBinding.subscriptionsSelectorList.adapter = null - feedGroupCreateBinding.iconSelector.adapter = null - - _feedGroupCreateBinding = null - _searchLayoutBinding = null - } - - /*/​////////////////////////////////////////////////////////////////////////// - // Setup - //​//////////////////////////////////////////////////////////////////////// */ - - override fun onBackPressed(): Boolean { - if (currentScreen is SubscriptionsPickerScreen && isSearchVisible()) { - hideSearch() - return true - } else if (currentScreen !is InitialScreen) { - showScreen(InitialScreen) - return true - } - - return false - } - - private fun setupListeners() { - feedGroupCreateBinding.deleteButton.setOnClickListener { showScreen(DeleteScreen) } - - feedGroupCreateBinding.cancelButton.setOnClickListener { - when (currentScreen) { - InitialScreen -> dismiss() - else -> showScreen(InitialScreen) - } - } - - feedGroupCreateBinding.groupNameInputContainer.error = null - feedGroupCreateBinding.groupNameInput.doOnTextChanged { text, _, _, _ -> - if (feedGroupCreateBinding.groupNameInputContainer.isErrorEnabled && !text.isNullOrBlank()) { - feedGroupCreateBinding.groupNameInputContainer.error = null - } - } - - feedGroupCreateBinding.confirmButton.setOnClickListener { handlePositiveButton() } - - feedGroupCreateBinding.selectChannelButton.setOnClickListener { - feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) - showScreen(SubscriptionsPickerScreen) - } - - val headerMenu = feedGroupCreateBinding.subscriptionsHeaderToolbar.menu - requireActivity().menuInflater.inflate(R.menu.menu_feed_group_dialog, headerMenu) - - headerMenu.findItem(R.id.action_search).setOnMenuItemClickListener { - showSearch() - true - } - - headerMenu.findItem(R.id.feed_group_toggle_show_only_ungrouped_subscriptions).apply { - isChecked = subscriptionsShowOnlyUngrouped - setOnMenuItemClickListener { - subscriptionsShowOnlyUngrouped = !subscriptionsShowOnlyUngrouped - it.isChecked = subscriptionsShowOnlyUngrouped - viewModel.toggleShowOnlyUngrouped(subscriptionsShowOnlyUngrouped) - true - } - } - - searchLayoutBinding.toolbarSearchClear.setOnClickListener { - if (searchLayoutBinding.toolbarSearchEditText.text.isNullOrEmpty()) { - hideSearch() - return@setOnClickListener - } - resetSearch() - showKeyboardSearch() - } - - searchLayoutBinding.toolbarSearchEditText.setOnClickListener { - if (DeviceUtils.isTv(context)) { - showKeyboardSearch() - } - } - - searchLayoutBinding.toolbarSearchEditText.doOnTextChanged { _, _, _, _ -> - val newQuery: String = searchLayoutBinding.toolbarSearchEditText.text.toString() - subscriptionsCurrentSearchQuery = newQuery - viewModel.filterSubscriptionsBy(newQuery) - } - - subscriptionGroupAdapter.setOnItemClickListener(subscriptionPickerItemListener) - } - - private fun handlePositiveButton() = when { - currentScreen is InitialScreen -> handlePositiveButtonInitialScreen() - currentScreen is DeleteScreen -> viewModel.deleteGroup() - currentScreen is SubscriptionsPickerScreen && isSearchVisible() -> hideSearch() - else -> showScreen(InitialScreen) - } - - private fun handlePositiveButtonInitialScreen() { - val name = feedGroupCreateBinding.groupNameInput.text.toString().trim() - val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL - - if (name.isBlank()) { - feedGroupCreateBinding.groupNameInputContainer.error = getString(R.string.feed_group_dialog_empty_name) - feedGroupCreateBinding.groupNameInput.text = null - feedGroupCreateBinding.groupNameInput.requestFocus() - return - } else { - feedGroupCreateBinding.groupNameInputContainer.error = null - } - - if (selectedSubscriptions.isEmpty()) { - Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() - return - } - - when (groupId) { - NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) - else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder) - } - } - - private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { - val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL - val name = feedGroupEntity?.name ?: "" - groupIcon = feedGroupEntity?.icon - groupSortOrder = feedGroupEntity?.sortOrder ?: -1 - - val feedGroupIcon = selectedIcon ?: icon - feedGroupCreateBinding.iconPreview.setImageResource(feedGroupIcon.getDrawableRes()) - - if (feedGroupCreateBinding.groupNameInput.text.isNullOrBlank()) { - feedGroupCreateBinding.groupNameInput.setText(name) - } - } - - private val subscriptionPickerItemListener = OnItemClickListener { item, view -> - if (item is PickerSubscriptionItem) { - val subscriptionId = item.subscriptionEntity.uid - wasSubscriptionSelectionChanged = true - - val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { - this.selectedSubscriptions.remove(subscriptionId) - false - } else { - this.selectedSubscriptions.add(subscriptionId) - true - } - - item.updateSelected(view, isSelected) - updateSubscriptionSelectedCount() - } - } - - private fun setupSubscriptionPicker( - subscriptions: List, - selectedSubscriptions: Set - ) { - if (!wasSubscriptionSelectionChanged) { - this.selectedSubscriptions.addAll(selectedSubscriptions) - } - - updateSubscriptionSelectedCount() - - if (subscriptions.isEmpty()) { - subscriptionEmptyFooter.clear() - subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem()) - } else { - subscriptionEmptyFooter.clear() - } - - subscriptions.forEach { - it.isSelected = this@FeedGroupDialog.selectedSubscriptions - .contains(it.subscriptionEntity.uid) - } - - subscriptionMainSection.update(subscriptions, false) - - if (subscriptionsListState != null) { - feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onRestoreInstanceState(subscriptionsListState) - subscriptionsListState = null - } else { - feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) - } - } - - private fun updateSubscriptionSelectedCount() { - val selectedCount = this.selectedSubscriptions.size - val selectedCountText = resources.getQuantityString( - R.plurals.feed_group_dialog_selection_count, - selectedCount, - selectedCount - ) - feedGroupCreateBinding.selectedSubscriptionCountView.text = selectedCountText - feedGroupCreateBinding.subscriptionsHeaderInfo.text = selectedCountText - } - - private fun setupIconPicker() { - val groupAdapter = GroupieAdapter() - groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) }) - - feedGroupCreateBinding.iconSelector.apply { - layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) - adapter = groupAdapter - - if (iconsListState != null) { - layoutManager?.onRestoreInstanceState(iconsListState) - iconsListState = null - } - } - - groupAdapter.setOnItemClickListener { item, _ -> - when (item) { - is PickerIconItem -> { - selectedIcon = item.icon - feedGroupCreateBinding.iconPreview.setImageResource(item.iconRes) - - showScreen(InitialScreen) - } - } - } - feedGroupCreateBinding.iconPreview.setOnClickListener { - feedGroupCreateBinding.iconSelector.scrollToPosition(0) - showScreen(IconPickerScreen) - } - - if (groupId == NO_GROUP_SELECTED) { - val icon = selectedIcon ?: FeedGroupIcon.ALL - feedGroupCreateBinding.iconPreview.setImageResource(icon.getDrawableRes()) - } - } - - /*/​////////////////////////////////////////////////////////////////////////// - // Screen Selector - //​//////////////////////////////////////////////////////////////////////// */ - - private fun showScreen(screen: ScreenState) { - currentScreen = screen - - feedGroupCreateBinding.optionsRoot.onlyVisibleIn(InitialScreen) - feedGroupCreateBinding.iconSelector.onlyVisibleIn(IconPickerScreen) - feedGroupCreateBinding.subscriptionsSelector.onlyVisibleIn(SubscriptionsPickerScreen) - feedGroupCreateBinding.deleteScreenMessage.onlyVisibleIn(DeleteScreen) - - feedGroupCreateBinding.separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen) - feedGroupCreateBinding.cancelButton.onlyVisibleIn(InitialScreen, DeleteScreen) - - feedGroupCreateBinding.confirmButton.setText( - when { - currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create - else -> R.string.ok - } - ) - - feedGroupCreateBinding.deleteButton.isGone = currentScreen != InitialScreen || groupId == NO_GROUP_SELECTED - - hideKeyboard() - hideSearch() - } - - private fun View.onlyVisibleIn(vararg screens: ScreenState) { - isVisible = currentScreen in screens - } - - /*/​////////////////////////////////////////////////////////////////////////// - // Utils - //​//////////////////////////////////////////////////////////////////////// */ - - private fun isSearchVisible() = _searchLayoutBinding?.root?.visibility == View.VISIBLE - - private fun resetSearch() { - searchLayoutBinding.toolbarSearchEditText.setText("") - subscriptionsCurrentSearchQuery = "" - viewModel.clearSubscriptionsFilter() - } - - private fun hideSearch() { - resetSearch() - searchLayoutBinding.root.visibility = View.GONE - feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.VISIBLE - feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = true - hideKeyboardSearch() - } - - private fun showSearch() { - searchLayoutBinding.root.visibility = View.VISIBLE - feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.GONE - feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = false - showKeyboardSearch() - } - - private val inputMethodManager by lazy { - requireActivity().getSystemService()!! - } - - private fun showKeyboardSearch() { - if (searchLayoutBinding.toolbarSearchEditText.requestFocus()) { - inputMethodManager.showSoftInput( - searchLayoutBinding.toolbarSearchEditText, - InputMethodManager.SHOW_IMPLICIT - ) - } - } - - private fun hideKeyboardSearch() { - inputMethodManager.hideSoftInputFromWindow( - searchLayoutBinding.toolbarSearchEditText.windowToken, - InputMethodManager.HIDE_NOT_ALWAYS - ) - searchLayoutBinding.toolbarSearchEditText.clearFocus() - } - - private fun showKeyboard() { - if (feedGroupCreateBinding.groupNameInput.requestFocus()) { - inputMethodManager.showSoftInput( - feedGroupCreateBinding.groupNameInput, - InputMethodManager.SHOW_IMPLICIT - ) - } - } - - private fun hideKeyboard() { - inputMethodManager.hideSoftInputFromWindow( - feedGroupCreateBinding.groupNameInput.windowToken, - InputMethodManager.HIDE_NOT_ALWAYS - ) - feedGroupCreateBinding.groupNameInput.clearFocus() - } - - private fun disableInput() { - _feedGroupCreateBinding?.deleteButton?.isEnabled = false - _feedGroupCreateBinding?.confirmButton?.isEnabled = false - _feedGroupCreateBinding?.cancelButton?.isEnabled = false - isCancelable = false - - hideKeyboard() - } - - companion object { - private const val KEY_GROUP_ID = "KEY_GROUP_ID" - private const val NO_GROUP_SELECTED = -1L - - fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { - val dialog = FeedGroupDialog() - dialog.arguments = bundleOf(KEY_GROUP_ID to groupId) - return dialog - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt deleted file mode 100644 index d8eac2492..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.schabi.newpipe.local.subscription.dialog - -import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.processors.BehaviorProcessor -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.local.feed.FeedDatabaseManager -import org.schabi.newpipe.local.subscription.FeedGroupIcon -import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem - -class FeedGroupDialogViewModel( - applicationContext: Context, - private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - initialQuery: String = "", - initialShowOnlyUngrouped: Boolean = false -) : ViewModel() { - - private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) - private var subscriptionManager = SubscriptionManager(applicationContext) - - private var filterSubscriptions = BehaviorProcessor.create() - private var toggleShowOnlyUngrouped = BehaviorProcessor.create() - - private var subscriptionsFlowable = Flowable - .combineLatest( - filterSubscriptions.startWithItem(initialQuery), - toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped) - ) { t1: String, t2: Boolean -> Filter(t1, t2) } - .distinctUntilChanged() - .switchMap { (query, showOnlyUngrouped) -> - subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped) - }.map { list -> list.map { PickerSubscriptionItem(it) } } - - private val mutableGroupLiveData = MutableLiveData() - private val mutableSubscriptionsLiveData = MutableLiveData, Set>>() - private val mutableDialogEventLiveData = MutableLiveData() - val groupLiveData: LiveData = mutableGroupLiveData - val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData - val dialogEventLiveData: LiveData = mutableDialogEventLiveData - - private var actionProcessingDisposable: Disposable? = null - - private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId) - .subscribeOn(Schedulers.io()) - .subscribe(mutableGroupLiveData::postValue) - - private var subscriptionsDisposable = Flowable - .combineLatest( - subscriptionsFlowable, - feedDatabaseManager.subscriptionIdsForGroup(groupId) - ) { t1: List, t2: List -> t1 to t2.toSet() } - .subscribeOn(Schedulers.io()) - .subscribe(mutableSubscriptionsLiveData::postValue) - - override fun onCleared() { - super.onCleared() - actionProcessingDisposable?.dispose() - subscriptionsDisposable.dispose() - feedGroupDisposable.dispose() - } - - fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { - doAction( - feedDatabaseManager.createGroup(name, selectedIcon) - .flatMapCompletable { - feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) - } - ) - } - - fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) { - doAction( - feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList()) - .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))) - ) - } - - fun deleteGroup() { - doAction(feedDatabaseManager.deleteGroup(groupId)) - } - - private fun doAction(completable: Completable) { - if (actionProcessingDisposable == null) { - mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent - - actionProcessingDisposable = completable - .subscribeOn(Schedulers.io()) - .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } - } - } - - fun filterSubscriptionsBy(query: String) { - filterSubscriptions.onNext(query) - } - - fun clearSubscriptionsFilter() { - filterSubscriptions.onNext("") - } - - fun toggleShowOnlyUngrouped(showOnlyUngrouped: Boolean) { - toggleShowOnlyUngrouped.onNext(showOnlyUngrouped) - } - - sealed class DialogEvent { - data object ProcessingEvent : DialogEvent() - data object SuccessEvent : DialogEvent() - } - - data class Filter(val query: String, val showOnlyUngrouped: Boolean) - - companion object { - fun getFactory( - context: Context, - groupId: Long, - initialQuery: String, - initialShowOnlyUngrouped: Boolean - ) = viewModelFactory { - initializer { - FeedGroupDialogViewModel( - context.applicationContext, - groupId, - initialQuery, - initialShowOnlyUngrouped - ) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt deleted file mode 100644 index 11f034ba0..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.schabi.newpipe.local.subscription.dialog - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.evernote.android.state.State -import com.livefront.bridge.Bridge -import com.xwray.groupie.GroupieAdapter -import com.xwray.groupie.TouchCallback -import java.util.Collections -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.databinding.DialogFeedGroupReorderBinding -import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.ProcessingEvent -import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.SuccessEvent -import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem -import org.schabi.newpipe.util.ThemeHelper - -class FeedGroupReorderDialog : DialogFragment() { - private var _binding: DialogFeedGroupReorderBinding? = null - private val binding get() = _binding!! - - private lateinit var viewModel: FeedGroupReorderDialogViewModel - - @State - @JvmField - var groupOrderedIdList = ArrayList() - private val groupAdapter = GroupieAdapter() - private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback()) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - Bridge.restoreInstanceState(this, savedInstanceState) - - setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.dialog_feed_group_reorder, container) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = DialogFeedGroupReorderBinding.bind(view) - - viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java) - viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) - viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { - when (it) { - ProcessingEvent -> disableInput() - SuccessEvent -> dismiss() - } - } - - binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext()) - binding.feedGroupsList.adapter = groupAdapter - itemTouchHelper.attachToRecyclerView(binding.feedGroupsList) - - binding.confirmButton.setOnClickListener { - viewModel.updateOrder(groupOrderedIdList) - } - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - Bridge.saveInstanceState(this, outState) - } - - private fun handleGroups(list: List) { - val groupList: List - - if (groupOrderedIdList.isEmpty()) { - groupList = list - groupOrderedIdList.addAll(groupList.map { it.uid }) - } else { - groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) } - } - - groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) }) - } - - private fun disableInput() { - _binding?.confirmButton?.isEnabled = false - isCancelable = false - } - - private fun getItemTouchCallback(): SimpleCallback { - return object : TouchCallback() { - - override fun onMove( - recyclerView: RecyclerView, - source: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val sourceIndex = source.bindingAdapterPosition - val targetIndex = target.bindingAdapterPosition - - groupAdapter.notifyItemMoved(sourceIndex, targetIndex) - Collections.swap(groupOrderedIdList, sourceIndex, targetIndex) - - return true - } - - override fun isLongPressDragEnabled(): Boolean = false - override fun isItemViewSwipeEnabled(): Boolean = false - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {} - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt deleted file mode 100644 index e1b3429b9..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.local.subscription.dialog - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.local.feed.FeedDatabaseManager - -class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) { - private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) - - private val mutableGroupsLiveData = MutableLiveData>() - private val mutableDialogEventLiveData = MutableLiveData() - val groupsLiveData: LiveData> = mutableGroupsLiveData - val dialogEventLiveData: LiveData = mutableDialogEventLiveData - - private var actionProcessingDisposable: Disposable? = null - - private var groupsDisposable = feedDatabaseManager.groups() - .take(1) - .subscribeOn(Schedulers.io()) - .subscribe(mutableGroupsLiveData::postValue) - - override fun onCleared() { - super.onCleared() - actionProcessingDisposable?.dispose() - groupsDisposable.dispose() - } - - fun updateOrder(groupIdList: List) { - doAction(feedDatabaseManager.updateGroupsOrder(groupIdList)) - } - - private fun doAction(completable: Completable) { - if (actionProcessingDisposable == null) { - mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent - - actionProcessingDisposable = completable - .subscribeOn(Schedulers.io()) - .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } - } - } - - sealed class DialogEvent { - object ProcessingEvent : DialogEvent() - object SuccessEvent : DialogEvent() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt deleted file mode 100644 index 7687a7d6d..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.content.Context -import android.widget.ImageView -import android.widget.TextView -import com.xwray.groupie.GroupieViewHolder -import com.xwray.groupie.Item -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.image.CoilHelper - -class ChannelItem( - private val infoItem: ChannelInfoItem, - private val subscriptionId: Long = -1L, - var itemVersion: ItemVersion = ItemVersion.NORMAL, - var gesturesListener: OnClickGesture? = null -) : Item() { - override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId - - enum class ItemVersion { NORMAL, MINI, GRID } - - override fun getLayout(): Int = when (itemVersion) { - ItemVersion.NORMAL -> R.layout.list_channel_item - ItemVersion.MINI -> R.layout.list_channel_mini_item - ItemVersion.GRID -> R.layout.list_channel_grid_item - } - - override fun bind(viewHolder: GroupieViewHolder, position: Int) { - val itemTitleView = viewHolder.root.findViewById(R.id.itemTitleView) - val itemAdditionalDetails = viewHolder.root.findViewById(R.id.itemAdditionalDetails) - val itemChannelDescriptionView = viewHolder.root.findViewById(R.id.itemChannelDescriptionView) - val itemThumbnailView = viewHolder.root.findViewById(R.id.itemThumbnailView) - - itemTitleView.text = infoItem.name - itemAdditionalDetails.text = getDetailLine(viewHolder.root.context) - if (itemVersion == ItemVersion.NORMAL) { - itemChannelDescriptionView.text = infoItem.description - } - - CoilHelper.loadAvatar(itemThumbnailView, infoItem.thumbnails) - - gesturesListener?.run { - viewHolder.root.setOnClickListener { selected(infoItem) } - viewHolder.root.setOnLongClickListener { - held(infoItem) - true - } - } - } - - private fun getDetailLine(context: Context): String { - var details = if (infoItem.subscriberCount >= 0) { - Localization.shortSubscriberCount(context, infoItem.subscriberCount) - } else { - context.getString(R.string.subscribers_count_not_available) - } - - if (itemVersion == ItemVersion.NORMAL && infoItem.streamCount >= 0) { - val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount) - details = Localization.concatenateStrings(details, formattedVideoAmount) - } - return details - } - - override fun getSpanSize(spanCount: Int, position: Int): Int { - return if (itemVersion == ItemVersion.GRID) 1 else spanCount - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt deleted file mode 100644 index a2870b849..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FeedGroupAddNewGridItemBinding - -class FeedGroupAddNewGridItem : BindableItem() { - override fun getLayout(): Int = R.layout.feed_group_add_new_grid_item - override fun initializeViewBinding(view: View) = FeedGroupAddNewGridItemBinding.bind(view) - override fun bind(viewBinding: FeedGroupAddNewGridItemBinding, position: Int) { - // this is a static item, nothing to do here - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt deleted file mode 100644 index e06e578f8..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FeedGroupAddNewItemBinding - -class FeedGroupAddNewItem : BindableItem() { - override fun getLayout(): Int = R.layout.feed_group_add_new_item - override fun initializeViewBinding(view: View) = FeedGroupAddNewItemBinding.bind(view) - override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) { - // this is a static item, nothing to do here - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt deleted file mode 100644 index c78801c03..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.databinding.FeedGroupCardGridItemBinding -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -data class FeedGroupCardGridItem( - val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - val name: String, - val icon: FeedGroupIcon -) : BindableItem() { - constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) - - override fun getId(): Long { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> super.getId() - else -> groupId - } - } - - override fun getLayout(): Int = R.layout.feed_group_card_grid_item - - override fun bind(viewBinding: FeedGroupCardGridItemBinding, position: Int) { - viewBinding.title.text = name - viewBinding.icon.setImageResource(icon.getDrawableRes()) - } - - override fun initializeViewBinding(view: View) = FeedGroupCardGridItemBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt deleted file mode 100644 index 7b78b3d95..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.databinding.FeedGroupCardItemBinding -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -data class FeedGroupCardItem( - val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - val name: String, - val icon: FeedGroupIcon -) : BindableItem() { - constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) - - override fun getId(): Long { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> super.getId() - else -> groupId - } - } - - override fun getLayout(): Int = R.layout.feed_group_card_item - - override fun bind(viewBinding: FeedGroupCardItemBinding, position: Int) { - viewBinding.title.text = name - viewBinding.icon.setImageResource(icon.getDrawableRes()) - } - - override fun initializeViewBinding(view: View) = FeedGroupCardItemBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt deleted file mode 100644 index bf9f9072f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.os.Parcelable -import android.view.View -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import com.xwray.groupie.GroupAdapter -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FeedItemCarouselBinding -import org.schabi.newpipe.util.DeviceUtils -import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount - -class FeedGroupCarouselItem( - private val carouselAdapter: GroupAdapter>, - var listViewMode: Boolean -) : BindableItem() { - companion object { - const val PAYLOAD_UPDATE_LIST_VIEW_MODE = 2 - } - - private var carouselLayoutManager: LinearLayoutManager? = null - private var listState: Parcelable? = null - - override fun getLayout() = R.layout.feed_item_carousel - - fun onSaveInstanceState(): Parcelable? { - listState = carouselLayoutManager?.onSaveInstanceState() - return listState - } - - fun onRestoreInstanceState(state: Parcelable?) { - carouselLayoutManager?.onRestoreInstanceState(state) - listState = state - } - - override fun initializeViewBinding(view: View): FeedItemCarouselBinding { - val viewBinding = FeedItemCarouselBinding.bind(view) - updateViewMode(viewBinding) - return viewBinding - } - - override fun bind( - viewBinding: FeedItemCarouselBinding, - position: Int, - payloads: MutableList - ) { - if (payloads.contains(PAYLOAD_UPDATE_LIST_VIEW_MODE)) { - updateViewMode(viewBinding) - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun bind(viewBinding: FeedItemCarouselBinding, position: Int) { - viewBinding.recyclerView.apply { adapter = carouselAdapter } - carouselLayoutManager?.onRestoreInstanceState(listState) - } - - override fun unbind(viewHolder: GroupieViewHolder) { - super.unbind(viewHolder) - listState = carouselLayoutManager?.onSaveInstanceState() - } - - private fun updateViewMode(viewBinding: FeedItemCarouselBinding) { - viewBinding.recyclerView.apply { adapter = carouselAdapter } - - val context = viewBinding.root.context - carouselLayoutManager = if (listViewMode) { - LinearLayoutManager(context) - } else { - GridLayoutManager(context, getGridSpanCount(context, DeviceUtils.dpToPx(112, context))) - } - - viewBinding.recyclerView.apply { - layoutManager = carouselLayoutManager - adapter = carouselAdapter - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt deleted file mode 100644 index 9a33de54d..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.MotionEvent -import android.view.View -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ItemTouchHelper.DOWN -import androidx.recyclerview.widget.ItemTouchHelper.UP -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.databinding.FeedGroupReorderItemBinding -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -data class FeedGroupReorderItem( - val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - val name: String, - val icon: FeedGroupIcon, - val dragCallback: ItemTouchHelper -) : BindableItem() { - constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper) : - this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback) - - override fun getId(): Long { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> super.getId() - else -> groupId - } - } - - override fun getLayout(): Int = R.layout.feed_group_reorder_item - - override fun bind(viewBinding: FeedGroupReorderItemBinding, position: Int) { - viewBinding.groupName.text = name - viewBinding.groupIcon.setImageResource(icon.getDrawableRes()) - } - - override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { - super.bind(viewHolder, position, payloads) - viewHolder.binding.handle.setOnTouchListener { _, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - dragCallback.startDrag(viewHolder) - return@setOnTouchListener true - } - - false - } - } - - override fun getDragDirs(): Int { - return UP or DOWN - } - - override fun initializeViewBinding(view: View) = FeedGroupReorderItemBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt deleted file mode 100644 index 8d5088890..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import androidx.core.view.isVisible -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.SubscriptionGroupsHeaderBinding - -class GroupsHeader( - private val title: String, - private val onSortClicked: () -> Unit, - private val onToggleListViewModeClicked: () -> Unit, - var showSortButton: Boolean = true, - var listViewMode: Boolean = true -) : BindableItem() { - companion object { - const val PAYLOAD_UPDATE_ICONS = 1 - } - - override fun getLayout(): Int = R.layout.subscription_groups_header - - override fun bind( - viewBinding: SubscriptionGroupsHeaderBinding, - position: Int, - payloads: MutableList - ) { - if (payloads.contains(PAYLOAD_UPDATE_ICONS)) { - updateIcons(viewBinding) - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun bind(viewBinding: SubscriptionGroupsHeaderBinding, position: Int) { - viewBinding.headerTitle.text = title - viewBinding.headerSort.setOnClickListener { onSortClicked() } - viewBinding.headerToggleViewMode.setOnClickListener { onToggleListViewModeClicked() } - updateIcons(viewBinding) - } - - override fun initializeViewBinding(view: View) = SubscriptionGroupsHeaderBinding.bind(view) - - private fun updateIcons(viewBinding: SubscriptionGroupsHeaderBinding) { - viewBinding.headerToggleViewMode.setImageResource( - if (listViewMode) R.drawable.ic_apps else R.drawable.ic_list - ) - viewBinding.headerSort.isVisible = showSortButton - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt deleted file mode 100644 index 87a3ac768..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.SubscriptionHeaderBinding - -class Header(private val title: String) : BindableItem() { - - override fun getLayout(): Int = R.layout.subscription_header - - override fun bind(viewBinding: SubscriptionHeaderBinding, position: Int) { - viewBinding.root.text = title - } - - override fun initializeViewBinding(view: View) = SubscriptionHeaderBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt deleted file mode 100644 index 93b551895..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ListEmptyViewBinding - -/** - * When there are no subscriptions, show a hint to the user about how to import subscriptions - */ -class ImportSubscriptionsHintPlaceholderItem : BindableItem() { - override fun getLayout(): Int = R.layout.list_empty_view_subscriptions - override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {} - override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount - override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt deleted file mode 100644 index b4232f666..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import androidx.annotation.DrawableRes -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.PickerIconItemBinding -import org.schabi.newpipe.local.subscription.FeedGroupIcon - -class PickerIconItem( - val icon: FeedGroupIcon -) : BindableItem() { - @DrawableRes - val iconRes: Int = icon.getDrawableRes() - - override fun getLayout(): Int = R.layout.picker_icon_item - - override fun bind(viewBinding: PickerIconItemBinding, position: Int) { - viewBinding.iconView.setImageResource(iconRes) - } - - override fun initializeViewBinding(view: View) = PickerIconItemBinding.bind(view) -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt deleted file mode 100644 index da35447e3..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import androidx.core.view.isGone -import androidx.core.view.isVisible -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding -import org.schabi.newpipe.ktx.AnimationType -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.util.image.CoilHelper - -data class PickerSubscriptionItem( - val subscriptionEntity: SubscriptionEntity, - var isSelected: Boolean = false -) : BindableItem() { - override fun getId(): Long = subscriptionEntity.uid - override fun getLayout(): Int = R.layout.picker_subscription_item - override fun getSpanSize(spanCount: Int, position: Int): Int = 1 - - override fun bind(viewBinding: PickerSubscriptionItemBinding, position: Int) { - CoilHelper.loadAvatar(viewBinding.thumbnailView, subscriptionEntity.avatarUrl) - viewBinding.titleView.text = subscriptionEntity.name - viewBinding.selectedHighlight.isVisible = isSelected - } - - override fun unbind(viewHolder: GroupieViewHolder) { - super.unbind(viewHolder) - - viewHolder.binding.selectedHighlight.apply { - animate().setListener(null).cancel() - isGone = true - alpha = 1F - } - } - - override fun initializeViewBinding(view: View) = PickerSubscriptionItemBinding.bind(view) - - fun updateSelected(containerView: View, isSelected: Boolean) { - this.isSelected = isSelected - PickerSubscriptionItemBinding.bind(containerView).selectedHighlight - .animate(isSelected, 150, AnimationType.LIGHT_SCALE_AND_ALPHA) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt deleted file mode 100644 index b95bcd508..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * ImportExportJsonHelper.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.subscription.workers - -import java.io.InputStream -import java.io.OutputStream -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException - -/** - * A JSON implementation capable of importing and exporting subscriptions, it has the advantage - * of being able to transfer subscriptions to any device. - */ -object ImportExportJsonHelper { - private val json = Json { encodeDefaults = true } - - /** - * Read a JSON source through the input stream. - * - * @param in the input stream (e.g. a file) - * @return the parsed subscription items - */ - @JvmStatic - @Throws(InvalidSourceException::class) - fun readFrom(`in`: InputStream?): List { - if (`in` == null) { - throw InvalidSourceException("input is null") - } - - try { - @OptIn(ExperimentalSerializationApi::class) - return json.decodeFromStream(`in`).subscriptions - } catch (e: Throwable) { - throw InvalidSourceException("Couldn't parse json", e) - } - } - - /** - * Write the subscriptions items list as JSON to the output. - * - * @param items the list of subscriptions items - * @param out the output stream (e.g. a file) - */ - @OptIn(ExperimentalSerializationApi::class) - @JvmStatic - fun writeTo( - items: List, - out: OutputStream - ) { - json.encodeToStream(SubscriptionData(items), out) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt deleted file mode 100644 index 174ae7585..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.local.subscription.workers - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.schabi.newpipe.BuildConfig - -@Serializable -class SubscriptionData( - val subscriptions: List -) { - @SerialName("app_version") - private val appVersion = BuildConfig.VERSION_NAME - - @SerialName("app_version_int") - private val appVersionInt = BuildConfig.VERSION_CODE -} - -@Serializable -data class SubscriptionItem( - @SerialName("service_id") - val serviceId: Int, - val url: String, - val name: String -) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt deleted file mode 100644 index 09e99aa6f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.schabi.newpipe.local.subscription.workers - -import android.content.Context -import android.content.pm.ServiceInfo -import android.net.Uri -import android.os.Build -import android.util.Log -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.core.net.toUri -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.reactive.awaitFirst -import kotlinx.coroutines.withContext -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.R - -class SubscriptionExportWorker( - appContext: Context, - params: WorkerParameters -) : CoroutineWorker(appContext, params) { - // This is needed for API levels < 31 (Android S). - override suspend fun getForegroundInfo(): ForegroundInfo { - return createForegroundInfo(applicationContext.getString(R.string.export_ongoing)) - } - - override suspend fun doWork(): Result { - return try { - val uri = inputData.getString(EXPORT_PATH)!!.toUri() - val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO() - val subscriptions = - table.getAll() - .awaitFirst() - .map { SubscriptionItem(it.serviceId, it.url ?: "", it.name ?: "") } - - val qty = subscriptions.size - val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty) - setForeground(createForegroundInfo(title)) - - withContext(Dispatchers.IO) { - // Truncate file if it already exists - applicationContext.contentResolver.openOutputStream(uri, "wt")?.use { - ImportExportJsonHelper.writeTo(subscriptions, it) - } - } - - if (BuildConfig.DEBUG) { - Log.i(TAG, "Exported $qty subscriptions") - } - - withContext(Dispatchers.Main) { - Toast - .makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT) - .show() - } - - Result.success() - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.e(TAG, "Error while exporting subscriptions", e) - } - - withContext(Dispatchers.Main) { - Toast - .makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT) - .show() - } - - return Result.failure() - } - } - - private fun createForegroundInfo(title: String): ForegroundInfo { - val notification = - NotificationCompat - .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setOngoing(true) - .setProgress(-1, -1, true) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setContentTitle(title) - .build() - val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 - return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) - } - - companion object { - private const val TAG = "SubscriptionExportWork" - private const val NOTIFICATION_ID = 4567 - private const val NOTIFICATION_CHANNEL_ID = "newpipe" - private const val WORK_NAME = "exportSubscriptions" - private const val EXPORT_PATH = "exportPath" - - fun schedule( - context: Context, - uri: Uri - ) { - val data = workDataOf(EXPORT_PATH to uri.toString()) - val workRequest = - OneTimeWorkRequestBuilder() - .setInputData(data) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - - WorkManager - .getInstance(context) - .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt deleted file mode 100644 index cc8cf6f24..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ /dev/null @@ -1,242 +0,0 @@ -package org.schabi.newpipe.local.subscription.workers - -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.Parcelable -import android.util.Log -import android.webkit.MimeTypeMap -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.core.net.toUri -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ForegroundInfo -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.rx3.await -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.ExtractorHelper - -class SubscriptionImportWorker( - appContext: Context, - params: WorkerParameters -) : CoroutineWorker(appContext, params) { - // This is needed for API levels < 31 (Android S). - override suspend fun getForegroundInfo(): ForegroundInfo { - return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0) - } - - override suspend fun doWork(): Result { - val subscriptions = - try { - loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData)) - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.e(TAG, "Error while loading subscriptions from path", e) - } - withContext(Dispatchers.Main) { - Toast - .makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) - .show() - } - return Result.failure() - } - - val mutex = Mutex() - var index = 1 - val qty = subscriptions.size - var title = - applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) - - val channelInfoList = - try { - withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { - subscriptions - .map { - async { - val channelInfo = - ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() - val channelTab = - ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() - - val currentIndex = mutex.withLock { index++ } - setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) - - channelInfo to channelTab - } - }.awaitAll() - } - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.e(TAG, "Error while loading subscription data", e) - } - withContext(Dispatchers.Main) { - Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) - .show() - } - return Result.failure() - } - - title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) - setForeground(createForegroundInfo(title, null, 0, 0)) - index = 0 - - val subscriptionManager = SubscriptionManager(applicationContext) - for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { - withContext(Dispatchers.IO) { - subscriptionManager.upsertAll(chunk) - } - index += chunk.size - setForeground(createForegroundInfo(title, null, index, qty)) - } - - withContext(Dispatchers.Main) { - Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) - .show() - } - - return Result.success() - } - - private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List { - return withContext(Dispatchers.IO) { - when (input) { - is SubscriptionImportInput.ChannelUrlMode -> - NewPipe.getService(input.serviceId).subscriptionExtractor - .fromChannelUrl(input.url) - .map { SubscriptionItem(it.serviceId, it.url, it.name) } - - is SubscriptionImportInput.InputStreamMode -> - applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { - val contentType = - MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME } - NewPipe.getService(input.serviceId).subscriptionExtractor - .fromInputStream(it, contentType) - .map { SubscriptionItem(it.serviceId, it.url, it.name) } - } - - is SubscriptionImportInput.PreviousExportMode -> - applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { - ImportExportJsonHelper.readFrom(it) - } - } ?: emptyList() - } - } - - private fun createForegroundInfo( - title: String, - text: String?, - currentProgress: Int, - maxProgress: Int - ): ForegroundInfo { - val notification = - NotificationCompat - .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setOngoing(true) - .setProgress(maxProgress, currentProgress, currentProgress == 0) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setContentTitle(title) - .setContentText(text) - .addAction( - R.drawable.ic_close, - applicationContext.getString(R.string.cancel), - WorkManager.getInstance(applicationContext).createCancelPendingIntent(id) - ).apply { - if (currentProgress > 0 && maxProgress > 0) { - val progressText = "$currentProgress/$maxProgress" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setSubText(progressText) - } else { - setContentInfo(progressText) - } - } - }.build() - val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 - - return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) - } - - companion object { - // Log tag length is limited to 23 characters on API levels < 24. - private const val TAG = "SubscriptionImport" - - private const val NOTIFICATION_ID = 4568 - private const val NOTIFICATION_CHANNEL_ID = "newpipe" - private const val DEFAULT_MIME = "application/octet-stream" - private const val PARALLEL_EXTRACTIONS = 8 - private const val BUFFER_COUNT_BEFORE_INSERT = 50 - - const val WORK_NAME = "SubscriptionImportWorker" - } -} - -sealed class SubscriptionImportInput : Parcelable { - @Parcelize - data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput() - - @Parcelize - data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput() - - @Parcelize - data class PreviousExportMode(val url: String) : SubscriptionImportInput() - - fun toData(): Data { - val (mode, serviceId, url) = when (this) { - is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url) - is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url) - is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url) - } - return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url) - } - - companion object { - - private const val CHANNEL_URL_MODE = 0 - private const val INPUT_STREAM_MODE = 1 - private const val PREVIOUS_EXPORT_MODE = 2 - - fun fromData(data: Data): SubscriptionImportInput { - val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE) - when (mode) { - CHANNEL_URL_MODE -> { - val serviceId = data.getInt("service_id", -1) - if (serviceId == -1) { - throw IllegalArgumentException("No service id provided") - } - val url = data.getString("url")!! - return ChannelUrlMode(serviceId, url) - } - - INPUT_STREAM_MODE -> { - val serviceId = data.getInt("service_id", -1) - if (serviceId == -1) { - throw IllegalArgumentException("No service id provided") - } - val url = data.getString("url")!! - return InputStreamMode(serviceId, url) - } - - PREVIOUS_EXPORT_MODE -> { - val url = data.getString("url")!! - return PreviousExportMode(url) - } - - else -> throw IllegalArgumentException("Unknown mode: $mode") - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java b/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java deleted file mode 100644 index c36a77421..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.schabi.newpipe.player; - -import android.content.Context; -import android.content.ContextWrapper; - -/** - * Fixes a leak caused by AudioManager using an Activity context. - * Tracked at https://android-review.googlesource.com/#/c/140481/1 and - * https://github.com/square/leakcanary/issues/205 - * Source: - * https://gist.github.com/jankovd/891d96f476f7a9ce24e2 - */ -public class AudioServiceLeakFix extends ContextWrapper { - AudioServiceLeakFix(final Context base) { - super(base); - } - - public static ContextWrapper preventLeakOf(final Context base) { - return new AudioServiceLeakFix(base); - } - - @Override - public Object getSystemService(final String name) { - if (Context.AUDIO_SERVICE.equals(name)) { - return getApplicationContext().getSystemService(name); - } - return super.getSystemService(name); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java deleted file mode 100644 index b6bb84ec6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ /dev/null @@ -1,677 +0,0 @@ -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; -import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; - -import android.content.ComponentName; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.IBinder; -import android.provider.Settings; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.SubMenu; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.SeekBar; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.exoplayer2.PlaybackParameters; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.event.PlayerEventListener; -import org.schabi.newpipe.player.helper.PlaybackParameterDialog; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.List; -import java.util.Optional; - -public final class PlayQueueActivity extends AppCompatActivity - implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, - View.OnClickListener, PlaybackParameterDialog.Callback { - - private static final String TAG = PlayQueueActivity.class.getSimpleName(); - - private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; - - private static final int MENU_ID_AUDIO_TRACK = 71; - - private Player player; - - private boolean serviceBound; - private ServiceConnection serviceConnection; - - private boolean seeking; - - //////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////// - - private ActivityPlayerQueueControlBinding queueControlBinding; - - private ItemTouchHelper itemTouchHelper; - - private Menu menu; - - //////////////////////////////////////////////////////////////////////////// - // Activity Lifecycle - //////////////////////////////////////////////////////////////////////////// - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); - - queueControlBinding = ActivityPlayerQueueControlBinding.inflate(getLayoutInflater()); - setContentView(queueControlBinding.getRoot()); - - setSupportActionBar(queueControlBinding.toolbar); - if (getSupportActionBar() != null) { - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(R.string.title_activity_play_queue); - } - - serviceConnection = getServiceConnection(); - bind(); - } - - @Override - public boolean onCreateOptionsMenu(final Menu m) { - this.menu = m; - getMenuInflater().inflate(R.menu.menu_play_queue, m); - getMenuInflater().inflate(R.menu.menu_play_queue_bg, m); - buildAudioTrackMenu(); - onMaybeMuteChanged(); - // to avoid null reference - if (player != null) { - onPlaybackParameterChanged(player.getPlaybackParameters()); - } - return true; - } - - // Allow to setup visibility of menuItems - @Override - public boolean onPrepareOptionsMenu(final Menu m) { - if (player != null) { - menu.findItem(R.id.action_switch_popup) - .setVisible(!player.popupPlayerSelected()); - menu.findItem(R.id.action_switch_background) - .setVisible(!player.audioPlayerSelected()); - } - return super.onPrepareOptionsMenu(m); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final int itemId = item.getItemId(); - if (itemId == android.R.id.home) { - finish(); - return true; - } else if (itemId == R.id.action_settings) { - NavigationHelper.openSettings(this); - return true; - } else if (itemId == R.id.action_append_playlist) { - PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); - return true; - } else if (itemId == R.id.action_playback_speed) { - openPlaybackParameterDialog(); - return true; - } else if (itemId == R.id.action_mute) { - player.toggleMute(); - return true; - } else if (itemId == R.id.action_system_audio) { - startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); - return true; - } else if (itemId == R.id.action_switch_main) { - this.player.setRecovery(); - NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); - return true; - } else if (itemId == R.id.action_switch_popup) { - if (PermissionHelper.isPopupEnabledElseAsk(this)) { - this.player.setRecovery(); - NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); - } - return true; - } else if (itemId == R.id.action_switch_background) { - this.player.setRecovery(); - NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); - return true; - } - - if (item.getGroupId() == MENU_ID_AUDIO_TRACK) { - onAudioTrackClick(item.getItemId()); - return true; - } - - return super.onOptionsItemSelected(item); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - unbind(); - } - - //////////////////////////////////////////////////////////////////////////// - // Service Connection - //////////////////////////////////////////////////////////////////////////// - - private void bind() { - // Note: this code should not really exist, and PlayerHolder should be used instead, but - // it will be rewritten when NewPlayer will replace the current player. - final Intent bindIntent = new Intent(this, PlayerService.class); - bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); - if (!success) { - unbindService(serviceConnection); - } - serviceBound = success; - } - - private void unbind() { - if (serviceBound) { - unbindService(serviceConnection); - serviceBound = false; - if (player != null) { - player.removeActivityListener(this); - } - - onQueueUpdate(null); - if (itemTouchHelper != null) { - itemTouchHelper.attachToRecyclerView(null); - } - - itemTouchHelper = null; - player = null; - } - } - - private ServiceConnection getServiceConnection() { - return new ServiceConnection() { - @Override - public void onServiceDisconnected(final ComponentName name) { - Log.d(TAG, "Player service is disconnected"); - } - - @Override - public void onServiceConnected(final ComponentName name, final IBinder service) { - Log.d(TAG, "Player service is connected"); - - if (service instanceof PlayerService.LocalBinder) { - player = ((PlayerService.LocalBinder) service).getService().getPlayer(); - } - - if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { - unbind(); - } else { - onQueueUpdate(player.getPlayQueue()); - buildComponents(); - if (player != null) { - player.setActivityListener(PlayQueueActivity.this); - } - } - } - }; - } - - //////////////////////////////////////////////////////////////////////////// - // Component Building - //////////////////////////////////////////////////////////////////////////// - - private void buildComponents() { - buildQueue(); - buildMetadata(); - buildSeekBar(); - buildControls(); - } - - private void buildQueue() { - queueControlBinding.playQueue.setLayoutManager(new LinearLayoutManager(this)); - queueControlBinding.playQueue.setClickable(true); - queueControlBinding.playQueue.setLongClickable(true); - queueControlBinding.playQueue.clearOnScrollListeners(); - queueControlBinding.playQueue.addOnScrollListener(getQueueScrollListener()); - - itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(queueControlBinding.playQueue); - } - - private void buildMetadata() { - queueControlBinding.metadata.setOnClickListener(this); - queueControlBinding.songName.setSelected(true); - queueControlBinding.artistName.setSelected(true); - } - - private void buildSeekBar() { - queueControlBinding.seekBar.setOnSeekBarChangeListener(this); - queueControlBinding.liveSync.setOnClickListener(this); - } - - private void buildControls() { - queueControlBinding.controlRepeat.setOnClickListener(this); - queueControlBinding.controlBackward.setOnClickListener(this); - queueControlBinding.controlFastRewind.setOnClickListener(this); - queueControlBinding.controlPlayPause.setOnClickListener(this); - queueControlBinding.controlFastForward.setOnClickListener(this); - queueControlBinding.controlForward.setOnClickListener(this); - queueControlBinding.controlShuffle.setOnClickListener(this); - } - - //////////////////////////////////////////////////////////////////////////// - // Component Helpers - //////////////////////////////////////////////////////////////////////////// - - private OnScrollBelowItemsListener getQueueScrollListener() { - return new OnScrollBelowItemsListener() { - @Override - public void onScrolledDown(final RecyclerView recyclerView) { - if (player != null && player.getPlayQueue() != null - && !player.getPlayQueue().isComplete()) { - player.getPlayQueue().fetch(); - } else { - queueControlBinding.playQueue.clearOnScrollListeners(); - } - } - }; - } - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new PlayQueueItemTouchCallback() { - @Override - public void onMove(final int sourceIndex, final int targetIndex) { - if (player != null) { - player.getPlayQueue().move(sourceIndex, targetIndex); - } - } - - @Override - public void onSwiped(final int index) { - if (index != -1) { - player.getPlayQueue().remove(index); - } - } - }; - } - - private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { - return new PlayQueueItemBuilder.OnSelectedListener() { - @Override - public void selected(final PlayQueueItem item, final View view) { - if (player != null) { - player.selectQueueItem(item); - } - } - - @Override - public void held(final PlayQueueItem item, final View view) { - if (player != null && player.getPlayQueue().indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, false, - getSupportFragmentManager(), PlayQueueActivity.this); - } - } - - @Override - public void onStartDrag(final PlayQueueItemHolder viewHolder) { - if (itemTouchHelper != null) { - itemTouchHelper.startDrag(viewHolder); - } - } - }; - } - - private void scrollToSelected() { - if (player == null) { - return; - } - - final int currentPlayingIndex = player.getPlayQueue().getIndex(); - final int currentVisibleIndex; - if (queueControlBinding.playQueue.getLayoutManager() instanceof LinearLayoutManager) { - final LinearLayoutManager layout = - (LinearLayoutManager) queueControlBinding.playQueue.getLayoutManager(); - currentVisibleIndex = layout.findFirstVisibleItemPosition(); - } else { - currentVisibleIndex = 0; - } - - final int distance = Math.abs(currentPlayingIndex - currentVisibleIndex); - if (distance < SMOOTH_SCROLL_MAXIMUM_DISTANCE) { - queueControlBinding.playQueue.smoothScrollToPosition(currentPlayingIndex); - } else { - queueControlBinding.playQueue.scrollToPosition(currentPlayingIndex); - } - } - - //////////////////////////////////////////////////////////////////////////// - // Component On-Click Listener - //////////////////////////////////////////////////////////////////////////// - - @Override - public void onClick(final View view) { - if (player == null) { - return; - } - - if (view.getId() == queueControlBinding.controlRepeat.getId()) { - player.cycleNextRepeatMode(); - } else if (view.getId() == queueControlBinding.controlBackward.getId()) { - player.playPrevious(); - } else if (view.getId() == queueControlBinding.controlFastRewind.getId()) { - player.fastRewind(); - } else if (view.getId() == queueControlBinding.controlPlayPause.getId()) { - player.playPause(); - } else if (view.getId() == queueControlBinding.controlFastForward.getId()) { - player.fastForward(); - } else if (view.getId() == queueControlBinding.controlForward.getId()) { - player.playNext(); - } else if (view.getId() == queueControlBinding.controlShuffle.getId()) { - player.toggleShuffleModeEnabled(); - } else if (view.getId() == queueControlBinding.metadata.getId()) { - scrollToSelected(); - } else if (view.getId() == queueControlBinding.liveSync.getId()) { - player.seekToDefault(); - } - } - - //////////////////////////////////////////////////////////////////////////// - // Playback Parameters - //////////////////////////////////////////////////////////////////////////// - - private void openPlaybackParameterDialog() { - if (player == null) { - return; - } - PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(), - player.getPlaybackSkipSilence(), this).show(getSupportFragmentManager(), TAG); - } - - @Override - public void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch, - final boolean playbackSkipSilence) { - if (player != null) { - player.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); - onPlaybackParameterChanged(player.getPlaybackParameters()); - } - } - - //////////////////////////////////////////////////////////////////////////// - // Seekbar Listener - //////////////////////////////////////////////////////////////////////////// - - @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - if (fromUser) { - final String seekTime = Localization.getDurationString(progress / 1000); - queueControlBinding.currentTime.setText(seekTime); - queueControlBinding.seekDisplay.setText(seekTime); - } - } - - @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - seeking = true; - queueControlBinding.seekDisplay.setVisibility(View.VISIBLE); - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - if (player != null) { - player.seekTo(seekBar.getProgress()); - } - queueControlBinding.seekDisplay.setVisibility(View.GONE); - seeking = false; - } - - //////////////////////////////////////////////////////////////////////////// - // Binding Service Listener - //////////////////////////////////////////////////////////////////////////// - - @Override - public void onQueueUpdate(@Nullable final PlayQueue queue) { - if (queue == null) { - queueControlBinding.playQueue.setAdapter(null); - } else { - final PlayQueueAdapter adapter = new PlayQueueAdapter(this, queue); - adapter.setSelectedListener(getOnSelectedListener()); - queueControlBinding.playQueue.setAdapter(adapter); - } - } - - @Override - public void onPlaybackUpdate(final int state, final int repeatMode, final boolean shuffled, - final PlaybackParameters parameters) { - onStateChanged(state); - onPlayModeChanged(repeatMode, shuffled); - onPlaybackParameterChanged(parameters); - onMaybeMuteChanged(); - } - - @Override - public void onProgressUpdate(final int currentProgress, final int duration, - final int bufferPercent) { - // Set buffer progress - queueControlBinding.seekBar.setSecondaryProgress((int) (queueControlBinding.seekBar.getMax() - * ((float) bufferPercent / 100))); - - // Set Duration - queueControlBinding.seekBar.setMax(duration); - queueControlBinding.endTime.setText(Localization.getDurationString(duration / 1000)); - - // Set current time if not seeking - if (!seeking) { - queueControlBinding.seekBar.setProgress(currentProgress); - queueControlBinding.currentTime.setText(Localization - .getDurationString(currentProgress / 1000)); - } - - if (player != null) { - queueControlBinding.liveSync.setClickable(!player.isLiveEdge()); - } - - // this will make sure progressCurrentTime has the same width as progressEndTime - final ViewGroup.LayoutParams currentTimeParams = - queueControlBinding.currentTime.getLayoutParams(); - currentTimeParams.width = queueControlBinding.endTime.getWidth(); - queueControlBinding.currentTime.setLayoutParams(currentTimeParams); - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - if (info != null) { - queueControlBinding.songName.setText(info.getName()); - queueControlBinding.artistName.setText(info.getUploaderName()); - - queueControlBinding.endTime.setVisibility(View.GONE); - queueControlBinding.liveSync.setVisibility(View.GONE); - switch (info.getStreamType()) { - case LIVE_STREAM: - case AUDIO_LIVE_STREAM: - queueControlBinding.liveSync.setVisibility(View.VISIBLE); - break; - default: - queueControlBinding.endTime.setVisibility(View.VISIBLE); - break; - } - - scrollToSelected(); - } - } - - @Override - public void onServiceStopped() { - unbind(); - finish(); - } - - //////////////////////////////////////////////////////////////////////////// - // Binding Service Helper - //////////////////////////////////////////////////////////////////////////// - - private void onStateChanged(final int state) { - final ImageButton playPauseButton = queueControlBinding.controlPlayPause; - switch (state) { - case Player.STATE_PAUSED: - playPauseButton.setImageResource(R.drawable.ic_play_arrow); - playPauseButton.setContentDescription(getString(R.string.play)); - break; - case Player.STATE_PLAYING: - playPauseButton.setImageResource(R.drawable.ic_pause); - playPauseButton.setContentDescription(getString(R.string.pause)); - break; - case Player.STATE_COMPLETED: - playPauseButton.setImageResource(R.drawable.ic_replay); - playPauseButton.setContentDescription(getString(R.string.replay)); - break; - default: - break; - } - - switch (state) { - case Player.STATE_PAUSED: - case Player.STATE_PLAYING: - case Player.STATE_COMPLETED: - queueControlBinding.controlPlayPause.setClickable(true); - queueControlBinding.controlPlayPause.setVisibility(View.VISIBLE); - queueControlBinding.controlProgressBar.setVisibility(View.GONE); - break; - default: - queueControlBinding.controlPlayPause.setClickable(false); - queueControlBinding.controlPlayPause.setVisibility(View.INVISIBLE); - queueControlBinding.controlProgressBar.setVisibility(View.VISIBLE); - break; - } - } - - private void onPlayModeChanged(final int repeatMode, final boolean shuffled) { - switch (repeatMode) { - case com.google.android.exoplayer2.Player.REPEAT_MODE_OFF: - queueControlBinding.controlRepeat.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off); - break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ONE: - queueControlBinding.controlRepeat.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one); - break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ALL: - queueControlBinding.controlRepeat.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all); - break; - } - - final int shuffleAlpha = shuffled ? 255 : 77; - queueControlBinding.controlShuffle.setImageAlpha(shuffleAlpha); - } - - private void onPlaybackParameterChanged(@Nullable final PlaybackParameters parameters) { - if (parameters != null && menu != null && player != null) { - final MenuItem item = menu.findItem(R.id.action_playback_speed); - item.setTitle(formatSpeed(parameters.speed)); - } - } - - private void onMaybeMuteChanged() { - if (menu != null && player != null) { - final MenuItem item = menu.findItem(R.id.action_mute); - - //Change the mute-button item in ActionBar - //1) Text change: - item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute); - - //2) Icon change accordingly to current App Theme - // using rootView.getContext() because getApplicationContext() didn't work - item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); - } - } - - @Override - public void onAudioTrackUpdate() { - buildAudioTrackMenu(); - } - - private void buildAudioTrackMenu() { - if (menu == null) { - return; - } - - final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track); - final List availableStreams = - Optional.ofNullable(player) - .map(Player::getCurrentMetadata) - .flatMap(MediaItemTag::getMaybeAudioTrack) - .map(MediaItemTag.AudioTrack::getAudioStreams) - .orElse(null); - final Optional selectedAudioStream = Optional.ofNullable(player) - .flatMap(Player::getSelectedAudioStream); - - if (availableStreams == null || availableStreams.size() < 2 - || selectedAudioStream.isEmpty()) { - audioTrackSelector.setVisible(false); - } else { - final SubMenu audioTrackMenu = audioTrackSelector.getSubMenu(); - audioTrackMenu.clear(); - - for (int i = 0; i < availableStreams.size(); i++) { - final AudioStream audioStream = availableStreams.get(i); - audioTrackMenu.add(MENU_ID_AUDIO_TRACK, i, Menu.NONE, - Localization.audioTrackName(this, audioStream)); - } - - final AudioStream s = selectedAudioStream.get(); - final String trackName = Localization.audioTrackName(this, s); - audioTrackSelector.setTitle( - getString(R.string.play_queue_audio_track, trackName)); - - final String shortName = s.getAudioLocale() != null - ? s.getAudioLocale().getLanguage() : trackName; - audioTrackSelector.setTitleCondensed( - shortName.substring(0, Math.min(shortName.length(), 2))); - audioTrackSelector.setVisible(true); - } - } - - /** - * Called when an item from the audio track selector is selected. - * - * @param itemId index of the selected item - */ - private void onAudioTrackClick(final int itemId) { - if (player.getCurrentMetadata() == null) { - return; - } - player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> { - final List availableStreams = audioTrack.getAudioStreams(); - final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); - if (selectedStreamIndex == itemId || availableStreams.size() <= itemId) { - return; - } - - final String newAudioTrack = availableStreams.get(itemId).getAudioTrackId(); - player.setAudioTrack(newAudioTrack); - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java deleted file mode 100644 index e458b707e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ /dev/null @@ -1,2483 +0,0 @@ -package org.schabi.newpipe.player; - -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NO_PERMISSION; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_UNSPECIFIED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_TIMEOUT; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; -import static com.google.android.exoplayer2.Player.DiscontinuityReason; -import static com.google.android.exoplayer2.Player.Listener; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static com.google.android.exoplayer2.Player.RepeatMode; -import static org.schabi.newpipe.extractor.ServiceList.YouTube; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs; -import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; -import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; -import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; -import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -import static coil3.Image_androidKt.toBitmap; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.media.AudioManager; -import android.support.v4.media.session.MediaSessionCompat; -import android.util.Log; -import android.view.LayoutInflater; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.content.IntentCompat; -import androidx.core.math.MathUtils; -import androidx.preference.PreferenceManager; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.PositionInfo; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.Tracks; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.text.CueGroup; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.video.VideoSize; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PlayerBinding; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.event.PlayerEventListener; -import org.schabi.newpipe.player.event.PlayerServiceEventListener; -import org.schabi.newpipe.player.helper.AudioReactor; -import org.schabi.newpipe.player.helper.CustomRenderersFactory; -import org.schabi.newpipe.player.helper.LoadController; -import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.player.playback.MediaSourceManager; -import org.schabi.newpipe.player.playback.PlaybackListener; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; -import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; -import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; -import org.schabi.newpipe.player.ui.BackgroundPlayerUi; -import org.schabi.newpipe.player.ui.MainPlayerUi; -import org.schabi.newpipe.player.ui.PlayerUi; -import org.schabi.newpipe.player.ui.PlayerUiList; -import org.schabi.newpipe.player.ui.PopupPlayerUi; -import org.schabi.newpipe.player.ui.VideoPlayerUi; -import org.schabi.newpipe.util.DependentPreferenceHelper; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.IntStream; - -import coil3.target.Target; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.disposables.SerialDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class Player implements PlaybackListener, Listener { - public static final boolean DEBUG = MainActivity.DEBUG; - public static final String TAG = Player.class.getSimpleName(); - - /*////////////////////////////////////////////////////////////////////////// - // States - //////////////////////////////////////////////////////////////////////////*/ - - public static final int STATE_PREFLIGHT = -1; - public static final int STATE_BLOCKED = 123; - public static final int STATE_PLAYING = 124; - public static final int STATE_BUFFERING = 125; - public static final int STATE_PAUSED = 126; - public static final int STATE_PAUSED_SEEK = 127; - public static final int STATE_COMPLETED = 128; - - /*////////////////////////////////////////////////////////////////////////// - // Intent - //////////////////////////////////////////////////////////////////////////*/ - - public static final String PLAYBACK_QUALITY = "playback_quality"; - public static final String PLAY_QUEUE_KEY = "play_queue_key"; - public static final String RESUME_PLAYBACK = "resume_playback"; - public static final String PLAY_WHEN_READY = "play_when_ready"; - public static final String PLAYER_TYPE = "player_type"; - public static final String PLAYER_INTENT_TYPE = "player_intent_type"; - public static final String PLAYER_INTENT_DATA = "player_intent_data"; - - /*////////////////////////////////////////////////////////////////////////// - // Time constants - //////////////////////////////////////////////////////////////////////////*/ - - public static final int PLAY_PREV_ACTIVATION_LIMIT_MILLIS = 5000; // 5 seconds - public static final int PROGRESS_LOOP_INTERVAL_MILLIS = 1000; // 1 second - - /*////////////////////////////////////////////////////////////////////////// - // Other constants - //////////////////////////////////////////////////////////////////////////*/ - - public static final int RENDERER_UNAVAILABLE = -1; - - /*////////////////////////////////////////////////////////////////////////// - // Playback - //////////////////////////////////////////////////////////////////////////*/ - - // play queue might be null e.g. while player is starting - @Nullable - private PlayQueue playQueue; - - @Nullable - private MediaSourceManager playQueueManager; - - @Nullable - private PlayQueueItem currentItem; - @Nullable - private MediaItemTag currentMetadata; - @Nullable - private Bitmap currentThumbnail; - @Nullable - private coil3.request.Disposable thumbnailDisposable; - - /*////////////////////////////////////////////////////////////////////////// - // Player - //////////////////////////////////////////////////////////////////////////*/ - - private ExoPlayer simpleExoPlayer; - private AudioReactor audioReactor; - - @NonNull - private final DefaultTrackSelector trackSelector; - @NonNull - private final LoadController loadController; - @NonNull - private final DefaultRenderersFactory renderFactory; - - @NonNull - private final VideoPlaybackResolver videoResolver; - @NonNull - private final AudioPlaybackResolver audioResolver; - - private final PlayerService service; //TODO try to remove and replace everything with context - - /*////////////////////////////////////////////////////////////////////////// - // Player states - //////////////////////////////////////////////////////////////////////////*/ - - private PlayerType playerType = PlayerType.MAIN; - private int currentState = STATE_PREFLIGHT; - - // audio only mode does not mean that player type is background, but that the player was - // minimized to background but will resume automatically to the original player type - private boolean isAudioOnly = false; - private boolean isPrepared = false; - - /*////////////////////////////////////////////////////////////////////////// - // UIs, listeners and disposables - //////////////////////////////////////////////////////////////////////////*/ - - @SuppressWarnings({"MemberName", "java:S116"}) // keep the unusual member name - private final PlayerUiList UIs; - - private BroadcastReceiver broadcastReceiver; - private IntentFilter intentFilter; - @Nullable - private PlayerServiceEventListener fragmentListener = null; - @Nullable - private PlayerEventListener activityListener = null; - - @NonNull - private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); - @NonNull - private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); - @NonNull - private final CompositeDisposable streamItemDisposable = new CompositeDisposable(); - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - private final Context context; - @NonNull - private final SharedPreferences prefs; - @NonNull - private final HistoryRecordManager recordManager; - - private boolean screenOn = true; - - /*////////////////////////////////////////////////////////////////////////// - // Constructor - //////////////////////////////////////////////////////////////////////////*/ - //region Constructor - - /** - * @param service the service this player resides in - * @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and - * could possibly be reused with multiple player instances - * @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service - * and could possibly be reused with multiple player instances - */ - public Player(@NonNull final PlayerService service, - @NonNull final MediaSessionCompat mediaSession, - @NonNull final MediaSessionConnector sessionConnector) { - this.service = service; - context = service; - prefs = PreferenceManager.getDefaultSharedPreferences(context); - recordManager = new HistoryRecordManager(context); - - setupBroadcastReceiver(); - - trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); - final PlayerDataSource dataSource = new PlayerDataSource(context, - new DefaultBandwidthMeter.Builder(context).build()); - loadController = new LoadController(); - - renderFactory = prefs.getBoolean( - context.getString( - R.string.always_use_exoplayer_set_output_surface_workaround_key), false) - ? new CustomRenderersFactory(context) : new DefaultRenderersFactory(context); - - renderFactory.setEnableDecoderFallback( - prefs.getBoolean( - context.getString( - R.string.use_exoplayer_decoder_fallback_key), false)); - - videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); - audioResolver = new AudioPlaybackResolver(context, dataSource); - - // The UIs added here should always be present. They will be initialized when the player - // reaches the initialization step. Make sure the media session ui is before the - // notification ui in the UIs list, since the notification depends on the media session in - // PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved. - UIs = new PlayerUiList( - new MediaSessionPlayerUi(this, mediaSession, sessionConnector), - new NotificationPlayerUi(this) - ); - } - - private VideoPlaybackResolver.QualityResolver getQualityResolver() { - return new VideoPlaybackResolver.QualityResolver() { - @Override - public int getDefaultResolutionIndex(final List sortedVideos) { - return videoPlayerSelected() - ? ListHelper.getDefaultResolutionIndex(context, sortedVideos) - : ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos); - } - - @Override - public int getOverrideResolutionIndex(final List sortedVideos, - final String playbackQuality) { - return videoPlayerSelected() - ? getResolutionIndex(context, sortedVideos, playbackQuality) - : getPopupResolutionIndex(context, sortedVideos, playbackQuality); - } - }; - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Playback initialization via intent - //////////////////////////////////////////////////////////////////////////*/ - //region Playback initialization via intent - - @SuppressWarnings("MethodLength") - public void handleIntent(@NonNull final Intent intent) { - final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE, - PlayerIntentType.class); - if (playerIntentType == null) { - return; - } - // TODO: this should be in the second switch below, but I’m not sure whether I - // can move the initUIs stuff without breaking the setup for edge cases somehow. - // when playing from a timestamp, keep the current player as-is. - if (playerIntentType != PlayerIntentType.TimestampChange) { - playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class); - } - initUIsForCurrentPlayerType(); - isAudioOnly = audioPlayerSelected(); - - if (intent.hasExtra(PLAYBACK_QUALITY)) { - videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); - } - - final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); - - switch (playerIntentType) { - case Enqueue -> { - if (playQueue != null) { - final PlayQueue newQueue = getPlayQueueFromCache(intent); - if (newQueue == null) { - return; - } - playQueue.append(newQueue.getStreams()); - return; - } - - // TODO: This falls through to the old logic, there was no playQueue - // yet so we should start the player and add the new video - break; - } - case EnqueueNext -> { - if (playQueue != null) { - final PlayQueue newQueue = getPlayQueueFromCache(intent); - if (newQueue == null) { - return; - } - final PlayQueueItem newItem = newQueue.getStreams().get(0); - playQueue.enqueueNext(newItem, false); - return; - } - - // TODO: This falls through to the old logic, there was no playQueue - // yet so we should start the player and add the new video - break; - } - case TimestampChange -> { - final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent, - PLAYER_INTENT_DATA, TimestampChangeData.class)); - final Single single = - ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false); - streamItemDisposable.add(single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final @Nullable PlayQueue oldPlayQueue = playQueue; - info.setStartPosition(data.getSeconds()); - final PlayQueueItem playQueueItem = new PlayQueueItem(info); - - // If the stream is already playing, - // we can just seek to the appropriate timestamp - if (oldPlayQueue != null - && playQueueItem.isSameItem(oldPlayQueue.getItem())) { - // Player can have state = IDLE when playback is stopped or failed - // and we should retry in this case - if (simpleExoPlayer.getPlaybackState() - == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.prepare(); - } - simpleExoPlayer.seekTo(oldPlayQueue.getIndex(), - data.getSeconds() * 1000L); - simpleExoPlayer.setPlayWhenReady(playWhenReady); - - } else { - final PlayQueue newPlayQueue; - - // If there is no queue yet, just add our item - if (oldPlayQueue == null) { - newPlayQueue = new SinglePlayQueue(playQueueItem); - - // else we add the timestamped stream behind the current video - // and start playing it. - } else { - oldPlayQueue.enqueueNext(playQueueItem, true); - oldPlayQueue.offsetIndex(1); - newPlayQueue = oldPlayQueue; - } - initPlayback(newPlayQueue, playWhenReady); - } - - }, throwable -> { - // This will only show a snackbar if the passed context has a root view: - // otherwise it will resort to showing a notification, so we are safe - // here. - final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, - data.getUrl(), null, data.getUrl()); - ErrorUtil.createNotification(context, info); - })); - return; - } - case AllOthers -> { - // fallthrough; TODO: put other intent data in separate cases - } - } - - final PlayQueue newQueue = getPlayQueueFromCache(intent); - if (newQueue == null) { - return; - } - - // branching parameters for below - final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); - - /* - * TODO As seen in #7427 this does not work: - * There are 3 situations when playback shouldn't be started from scratch (zero timestamp): - * 1. User pressed on a timestamp link and the same video should be rewound to the timestamp - * 2. User changed a player from, for example. main to popup, or from audio to main, etc - * 3. User chose to resume a video based on a saved timestamp from history of played videos - * In those cases time will be saved because re-init of the play queue is a not an instant - * task and requires network calls - * */ - // seek to timestamp if stream is already playing - if (!exoPlayerIsNull() - && newQueue.size() == 1 && newQueue.getItem() != null - && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null - && newQueue.getItem().isSameItem(playQueue.getItem()) - && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { - // Player can have state = IDLE when playback is stopped or failed - // and we should retry in this case - if (simpleExoPlayer.getPlaybackState() - == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.prepare(); - } - simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition()); - simpleExoPlayer.setPlayWhenReady(playWhenReady); - - } else if (!exoPlayerIsNull() - && samePlayQueue - && playQueue != null - && !playQueue.isDisposed()) { - // Do not re-init the same PlayQueue. Save time - // Player can have state = IDLE when playback is stopped or failed - // and we should retry in this case - if (simpleExoPlayer.getPlaybackState() - == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.prepare(); - } - simpleExoPlayer.setPlayWhenReady(playWhenReady); - - } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) - && DependentPreferenceHelper.getResumePlaybackEnabled(context) - // !samePlayQueue - && (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue)) - && !newQueue.isEmpty() - && newQueue.getItem() != null - && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { - databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem()) - .observeOn(AndroidSchedulers.mainThread()) - // Do not place initPlayback() in doFinally() because - // it restarts playback after destroy() - //.doFinally() - .subscribe( - state -> { - if (!state.isFinished(newQueue.getItem().getDuration())) { - // resume playback only if the stream was not played to the end - newQueue.setRecovery(newQueue.getIndex(), - state.getProgressMillis()); - } - initPlayback(newQueue, playWhenReady); - }, - error -> { - if (DEBUG) { - Log.w(TAG, "Failed to start playback", error); - } - // In case any error we can start playback without history - initPlayback(newQueue, playWhenReady); - }, - () -> { - // Completed but not found in history - initPlayback(newQueue, playWhenReady); - } - )); - } else { - // Good to go... - // In a case of equal PlayQueues we can re-init old one but only when it is disposed - initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady); - } - - } - - - public void handleIntentPost(final PlayerType oldPlayerType) { - if (oldPlayerType != playerType && playQueue != null) { - // If playerType changes from one to another we should reload the player - // (to disable/enable video stream or to set quality) - reloadPlayQueueManager(); - } - - UIs.call(PlayerUi::setupAfterIntent); - NavigationHelper.sendPlayerStartedEvent(context); - } - - @Nullable - private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) { - final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); - if (queueCache == null) { - return null; - } - return SerializedCache.getInstance().take(queueCache, PlayQueue.class); - } - - private void initUIsForCurrentPlayerType() { - if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO) - || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { - // correct UI already in place - return; - } - - // try to reuse binding if possible - final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) - .orElseGet(() -> { - if (playerType == PlayerType.AUDIO) { - return null; - } else { - return PlayerBinding.inflate(LayoutInflater.from(context)); - } - }); - - switch (playerType) { - case MAIN: - UIs.destroyAll(PopupPlayerUi.class); - UIs.destroyAll(BackgroundPlayerUi.class); - UIs.addAndPrepare(new MainPlayerUi(this, binding)); - break; - case POPUP: - UIs.destroyAll(MainPlayerUi.class); - UIs.destroyAll(BackgroundPlayerUi.class); - UIs.addAndPrepare(new PopupPlayerUi(this, binding)); - break; - case AUDIO: - UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi - UIs.addAndPrepare(new BackgroundPlayerUi(this)); - break; - } - } - - private void initPlayback(@NonNull final PlayQueue queue, - final boolean playOnReady) { - destroyPlayer(); - initPlayer(playOnReady); - final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( - R.string.playback_skip_silence_key), getPlaybackSkipSilence()); - final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); - setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence); - - playQueue = queue; - playQueue.init(); - reloadPlayQueueManager(); - - UIs.call(PlayerUi::initPlayback); - - simpleExoPlayer.setVolume(isMuted() ? 0 : 1); - notifyQueueUpdateToListeners(); - } - - private void initPlayer(final boolean playOnReady) { - if (DEBUG) { - Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); - } - - simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory) - .setTrackSelector(trackSelector) - .setLoadControl(loadController) - .setUsePlatformDiagnostics(false) - .build(); - simpleExoPlayer.addListener(this); - simpleExoPlayer.setPlayWhenReady(playOnReady); - simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); - simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); - simpleExoPlayer.setHandleAudioBecomingNoisy(true); - - audioReactor = new AudioReactor(context, simpleExoPlayer); - - registerBroadcastReceiver(); - - // Setup UIs - UIs.call(PlayerUi::initPlayer); - - // Disable media tunneling if requested by the user from ExoPlayer settings - if (!PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.disable_media_tunneling_key), false)) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setTunnelingEnabled(true)); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Destroy and recovery - //////////////////////////////////////////////////////////////////////////*/ - //region Destroy and recovery - - private void destroyPlayer() { - if (DEBUG) { - Log.d(TAG, "destroyPlayer() called"); - } - UIs.call(PlayerUi::destroyPlayer); - - if (!exoPlayerIsNull()) { - simpleExoPlayer.removeListener(this); - simpleExoPlayer.stop(); - simpleExoPlayer.release(); - } - if (isProgressLoopRunning()) { - stopProgressLoop(); - } - if (playQueue != null) { - playQueue.dispose(); - } - if (audioReactor != null) { - audioReactor.dispose(); - } - if (playQueueManager != null) { - playQueueManager.dispose(); - } - } - - public void destroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - - saveStreamProgressState(); - setRecovery(); - stopActivityBinding(); - - destroyPlayer(); - unregisterBroadcastReceiver(); - - databaseUpdateDisposable.clear(); - progressUpdateDisposable.set(null); - streamItemDisposable.clear(); - - UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object - } - - public void setRecovery() { - if (playQueue == null || exoPlayerIsNull()) { - return; - } - - final int queuePos = playQueue.getIndex(); - final long windowPos = simpleExoPlayer.getCurrentPosition(); - final long duration = simpleExoPlayer.getDuration(); - - // No checks due to https://github.com/TeamNewPipe/NewPipe/pull/7195#issuecomment-962624380 - setRecovery(queuePos, MathUtils.clamp(windowPos, 0, duration)); - } - - private void setRecovery(final int queuePos, final long windowPos) { - if (playQueue == null || playQueue.size() <= queuePos) { - return; - } - - if (DEBUG) { - Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); - } - playQueue.setRecovery(queuePos, windowPos); - } - - public void reloadPlayQueueManager() { - if (playQueueManager != null) { - playQueueManager.dispose(); - } - - if (playQueue != null) { - playQueueManager = new MediaSourceManager(this, playQueue); - } - } - - @Override // own playback listener - public void onPlaybackShutdown() { - if (DEBUG) { - Log.d(TAG, "onPlaybackShutdown() called"); - } - // destroys the service, which in turn will destroy the player - service.destroyPlayerAndStopService(); - } - - public void smoothStopForImmediateReusing() { - // Pausing would make transition from one stream to a new stream not smooth, so only stop - simpleExoPlayer.stop(); - setRecovery(); - UIs.call(PlayerUi::smoothStopForImmediateReusing); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Broadcast receiver - //////////////////////////////////////////////////////////////////////////*/ - //region Broadcast receiver - - /** - * This function prepares the broadcast receiver and is called only in the constructor. - * Therefore if you want any PlayerUi to receive a broadcast action, you should add it here, - * even if that player ui might never be added to the player. In that case the received - * broadcast would not do anything. - */ - private void setupBroadcastReceiver() { - if (DEBUG) { - Log.d(TAG, "setupBroadcastReceiver() called"); - } - - broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context ctx, final Intent intent) { - onBroadcastReceived(intent); - } - }; - intentFilter = new IntentFilter(); - - intentFilter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); - - intentFilter.addAction(ACTION_CLOSE); - intentFilter.addAction(ACTION_PLAY_PAUSE); - intentFilter.addAction(ACTION_PLAY_PREVIOUS); - intentFilter.addAction(ACTION_PLAY_NEXT); - intentFilter.addAction(ACTION_FAST_REWIND); - intentFilter.addAction(ACTION_FAST_FORWARD); - intentFilter.addAction(ACTION_REPEAT); - intentFilter.addAction(ACTION_SHUFFLE); - intentFilter.addAction(ACTION_RECREATE_NOTIFICATION); - - intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED); - intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED); - - intentFilter.addAction(Intent.ACTION_CONFIGURATION_CHANGED); - intentFilter.addAction(Intent.ACTION_SCREEN_ON); - intentFilter.addAction(Intent.ACTION_SCREEN_OFF); - intentFilter.addAction(Intent.ACTION_HEADSET_PLUG); - } - - private void onBroadcastReceived(final Intent intent) { - if (intent == null || intent.getAction() == null) { - return; - } - - if (DEBUG) { - Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); - } - - switch (intent.getAction()) { - case AudioManager.ACTION_AUDIO_BECOMING_NOISY: - pause(); - break; - case ACTION_CLOSE: - service.destroyPlayerAndStopService(); - break; - case ACTION_PLAY_PAUSE: - playPause(); - break; - case ACTION_PLAY_PREVIOUS: - playPrevious(); - break; - case ACTION_PLAY_NEXT: - playNext(); - break; - case ACTION_FAST_REWIND: - fastRewind(); - break; - case ACTION_FAST_FORWARD: - fastForward(); - break; - case ACTION_REPEAT: - cycleNextRepeatMode(); - break; - case ACTION_SHUFFLE: - toggleShuffleModeEnabled(); - break; - case Intent.ACTION_SCREEN_OFF: - screenOn = false; - break; - case Intent.ACTION_SCREEN_ON: - screenOn = true; - break; - case Intent.ACTION_CONFIGURATION_CHANGED: - if (DEBUG) { - Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received"); - } - break; - } - - UIs.call(playerUi -> playerUi.onBroadcastReceived(intent)); - } - - private void registerBroadcastReceiver() { - // Try to unregister current first - unregisterBroadcastReceiver(); - ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter, - ContextCompat.RECEIVER_EXPORTED); - } - - private void unregisterBroadcastReceiver() { - try { - context.unregisterReceiver(broadcastReceiver); - } catch (final IllegalArgumentException unregisteredException) { - Log.w(TAG, "Broadcast receiver already unregistered: " - + unregisteredException.getMessage()); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Thumbnail loading - //////////////////////////////////////////////////////////////////////////*/ - //region Thumbnail loading - - private void loadCurrentThumbnail(final List thumbnails) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = [" - + thumbnails.size() + "]"); - } - - // Cancel any ongoing image loading - if (thumbnailDisposable != null) { - thumbnailDisposable.dispose(); - } - - // Unset currentThumbnail, since it is now outdated. This ensures it is not used in media - // session metadata while the new thumbnail is being loaded by Coil. - onThumbnailLoaded(null); - if (thumbnails.isEmpty()) { - return; - } - - // scale down the notification thumbnail for performance - final var thumbnailTarget = new Target() { - @Override - public void onError(@Nullable final coil3.Image error) { - Log.e(TAG, "Thumbnail - onError() called"); - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(null); - } - - @Override - public void onStart(@Nullable final coil3.Image placeholder) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onStart() called"); - } - } - - @Override - public void onSuccess(@NonNull final coil3.Image result) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]"); - } - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(toBitmap(result)); - } - }; - thumbnailDisposable = CoilHelper.INSTANCE - .loadScaledDownThumbnail(context, thumbnails, thumbnailTarget); - } - - - private void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - // Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the - // thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since - // onThumbnailLoaded won't be called twice with the same nonnull bitmap by Coil's target. - if (currentThumbnail != bitmap) { - currentThumbnail = bitmap; - UIs.call(playerUi -> playerUi.onThumbnailLoaded(bitmap)); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Playback parameters - //////////////////////////////////////////////////////////////////////////*/ - //region Playback parameters - - public float getPlaybackSpeed() { - return getPlaybackParameters().speed; - } - - public void setPlaybackSpeed(final float speed) { - setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()); - } - - public float getPlaybackPitch() { - return getPlaybackParameters().pitch; - } - - public boolean getPlaybackSkipSilence() { - return !exoPlayerIsNull() && simpleExoPlayer.getSkipSilenceEnabled(); - } - - public PlaybackParameters getPlaybackParameters() { - if (exoPlayerIsNull()) { - return PlaybackParameters.DEFAULT; - } - return simpleExoPlayer.getPlaybackParameters(); - } - - /** - * Sets the playback parameters of the player, and also saves them to shared preferences. - * Speed and pitch are rounded up to 2 decimal places before being used or saved. - * - * @param speed the playback speed, will be rounded to up to 2 decimal places - * @param pitch the playback pitch, will be rounded to up to 2 decimal places - * @param skipSilence skip silence during playback - */ - public void setPlaybackParameters(final float speed, final float pitch, - final boolean skipSilence) { - final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f; - final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f; - - savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence); - simpleExoPlayer.setPlaybackParameters( - new PlaybackParameters(roundedSpeed, roundedPitch)); - simpleExoPlayer.setSkipSilenceEnabled(skipSilence); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Progress loop and updates - //////////////////////////////////////////////////////////////////////////*/ - //region Progress loop and updates - - private void onUpdateProgress(final int currentProgress, - final int duration, - final int bufferPercent) { - if (isPrepared) { - UIs.call(ui -> ui.onUpdateProgress(currentProgress, duration, bufferPercent)); - notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); - } - } - - public void startProgressLoop() { - progressUpdateDisposable.set(getProgressUpdateDisposable()); - } - - private void stopProgressLoop() { - progressUpdateDisposable.set(null); - } - - public boolean isProgressLoopRunning() { - return progressUpdateDisposable.get() != null; - } - - public void triggerProgressUpdate() { - if (exoPlayerIsNull()) { - return; - } - - onUpdateProgress(Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), - (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); - } - - private Disposable getProgressUpdateDisposable() { - return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, - AndroidSchedulers.mainThread()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> triggerProgressUpdate(), - error -> Log.e(TAG, "Progress update failure: ", error)); - } - - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Playback states - //////////////////////////////////////////////////////////////////////////*/ - //region Playback states - @Override - public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPlayWhenReadyChanged() called with: " - + "playWhenReady = [" + playWhenReady + "], " - + "reason = [" + reason + "]"); - } - final int playbackState = exoPlayerIsNull() - ? com.google.android.exoplayer2.Player.STATE_IDLE - : simpleExoPlayer.getPlaybackState(); - updatePlaybackState(playWhenReady, playbackState); - } - - @Override - public void onPlaybackStateChanged(final int playbackState) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPlaybackStateChanged() called with: " - + "playbackState = [" + playbackState + "]"); - } - updatePlaybackState(getPlayWhenReady(), playbackState); - } - - private void updatePlaybackState(final boolean playWhenReady, final int playbackState) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - updatePlaybackState() called with: " - + "playWhenReady = [" + playWhenReady + "], " - + "playbackState = [" + playbackState + "]"); - } - - if (currentState == STATE_PAUSED_SEEK) { - if (DEBUG) { - Log.d(TAG, "updatePlaybackState() is currently blocked"); - } - return; - } - - switch (playbackState) { - case com.google.android.exoplayer2.Player.STATE_IDLE: // 1 - isPrepared = false; - break; - case com.google.android.exoplayer2.Player.STATE_BUFFERING: // 2 - if (isPrepared) { - changeState(STATE_BUFFERING); - } - break; - case com.google.android.exoplayer2.Player.STATE_READY: //3 - if (!isPrepared) { - isPrepared = true; - onPrepared(playWhenReady); - } - changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); - break; - case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 - changeState(STATE_COMPLETED); - saveStreamProgressStateCompleted(); - isPrepared = false; - break; - } - } - - @Override // exoplayer listener - public void onIsLoadingChanged(final boolean isLoading) { - if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) { - stopProgressLoop(); - } else if (isLoading && !isProgressLoopRunning()) { - startProgressLoop(); - } - } - - @Override // own playback listener - public void onPlaybackBlock() { - if (exoPlayerIsNull()) { - return; - } - if (DEBUG) { - Log.d(TAG, "Playback - onPlaybackBlock() called"); - } - - currentItem = null; - currentMetadata = null; - simpleExoPlayer.stop(); - isPrepared = false; - - changeState(STATE_BLOCKED); - } - - @Override // own playback listener - public void onPlaybackUnblock(final MediaSource mediaSource) { - if (DEBUG) { - Log.d(TAG, "Playback - onPlaybackUnblock() called"); - } - - if (exoPlayerIsNull()) { - return; - } - if (currentState == STATE_BLOCKED) { - changeState(STATE_BUFFERING); - } - simpleExoPlayer.setMediaSource(mediaSource, false); - simpleExoPlayer.prepare(); - } - - public void changeState(final int state) { - if (DEBUG) { - Log.d(TAG, "changeState() called with: state = [" + state + "]"); - } - currentState = state; - switch (state) { - case STATE_BLOCKED: - onBlocked(); - break; - case STATE_PLAYING: - onPlaying(); - break; - case STATE_BUFFERING: - onBuffering(); - break; - case STATE_PAUSED: - onPaused(); - break; - case STATE_PAUSED_SEEK: - onPausedSeek(); - break; - case STATE_COMPLETED: - onCompleted(); - break; - } - notifyPlaybackUpdateToListeners(); - } - - private void onPrepared(final boolean playWhenReady) { - if (DEBUG) { - Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); - } - - UIs.call(PlayerUi::onPrepared); - - if (playWhenReady && !isMuted()) { - audioReactor.requestAudioFocus(); - } - } - - private void onBlocked() { - if (DEBUG) { - Log.d(TAG, "onBlocked() called"); - } - if (!isProgressLoopRunning()) { - startProgressLoop(); - } - - UIs.call(PlayerUi::onBlocked); - } - - private void onPlaying() { - if (DEBUG) { - Log.d(TAG, "onPlaying() called"); - } - if (!isProgressLoopRunning()) { - startProgressLoop(); - } - - UIs.call(PlayerUi::onPlaying); - } - - private void onBuffering() { - if (DEBUG) { - Log.d(TAG, "onBuffering() called"); - } - - UIs.call(PlayerUi::onBuffering); - } - - private void onPaused() { - if (DEBUG) { - Log.d(TAG, "onPaused() called"); - } - - if (isProgressLoopRunning()) { - stopProgressLoop(); - } - - UIs.call(PlayerUi::onPaused); - } - - private void onPausedSeek() { - if (DEBUG) { - Log.d(TAG, "onPausedSeek() called"); - } - UIs.call(PlayerUi::onPausedSeek); - } - - private void onCompleted() { - if (DEBUG) { - Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : "")); - } - if (playQueue == null) { - return; - } - - UIs.call(PlayerUi::onCompleted); - - if (playQueue.getIndex() < playQueue.size() - 1) { - playQueue.offsetIndex(+1); - } - if (isProgressLoopRunning()) { - stopProgressLoop(); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Repeat and shuffle - //////////////////////////////////////////////////////////////////////////*/ - //region Repeat and shuffle - - @RepeatMode - public int getRepeatMode() { - return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); - } - - public void cycleNextRepeatMode() { - if (!exoPlayerIsNull()) { - @RepeatMode final int repeatMode; - switch (simpleExoPlayer.getRepeatMode()) { - case REPEAT_MODE_OFF: - repeatMode = REPEAT_MODE_ONE; - break; - case REPEAT_MODE_ONE: - repeatMode = REPEAT_MODE_ALL; - break; - case REPEAT_MODE_ALL: - default: - repeatMode = REPEAT_MODE_OFF; - break; - } - simpleExoPlayer.setRepeatMode(repeatMode); - } - } - - @Override - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " - + "repeatMode = [" + repeatMode + "]"); - } - UIs.call(playerUi -> playerUi.onRepeatModeChanged(repeatMode)); - notifyPlaybackUpdateToListeners(); - } - - @Override - public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " - + "mode = [" + shuffleModeEnabled + "]"); - } - - if (playQueue != null) { - if (shuffleModeEnabled) { - playQueue.shuffle(); - } else { - playQueue.unshuffle(); - } - } - - UIs.call(playerUi -> playerUi.onShuffleModeEnabledChanged(shuffleModeEnabled)); - notifyPlaybackUpdateToListeners(); - } - - public void toggleShuffleModeEnabled() { - if (!exoPlayerIsNull()) { - simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Mute / Unmute - //////////////////////////////////////////////////////////////////////////*/ - //region Mute / Unmute - - public void toggleMute() { - final boolean wasMuted = isMuted(); - simpleExoPlayer.setVolume(wasMuted ? 1 : 0); - if (wasMuted) { - audioReactor.requestAudioFocus(); - } else { - audioReactor.abandonAudioFocus(); - } - UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted)); - notifyPlaybackUpdateToListeners(); - } - - public boolean isMuted() { - return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0; - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // ExoPlayer listeners (that didn't fit in other categories) - //////////////////////////////////////////////////////////////////////////*/ - //region ExoPlayer listeners (that didn't fit in other categories) - - /** - *

Listens for event or state changes on ExoPlayer. When any event happens, we check for - * changes in the currently-playing metadata and update the encapsulating - * {@link Player}. Downstream listeners are also informed.

- * - *

When the renewed metadata contains any error, it is reported as a notification. - * This is done because not all source resolution errors are {@link PlaybackException}, which - * are also captured by {@link ExoPlayer} and stops the playback.

- * - * @param player The {@link com.google.android.exoplayer2.Player} whose state changed. - * @param events The {@link com.google.android.exoplayer2.Player.Events} that has triggered - * the player state changes. - **/ - @Override - public void onEvents(@NonNull final com.google.android.exoplayer2.Player player, - @NonNull final com.google.android.exoplayer2.Player.Events events) { - Listener.super.onEvents(player, events); - MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> { - if (tag == currentMetadata) { - return; // we still have the same metadata, no need to do anything - } - final StreamInfo previousInfo = Optional.ofNullable(currentMetadata) - .flatMap(MediaItemTag::getMaybeStreamInfo).orElse(null); - final MediaItemTag.AudioTrack previousAudioTrack = - Optional.ofNullable(currentMetadata) - .flatMap(MediaItemTag::getMaybeAudioTrack).orElse(null); - currentMetadata = tag; - - if (!currentMetadata.getErrors().isEmpty()) { - // new errors might have been added even if previousInfo == tag.getMaybeStreamInfo() - final ErrorInfo errorInfo = new ErrorInfo( - currentMetadata.getErrors(), - UserAction.PLAY_STREAM, - "Loading failed for [" + currentMetadata.getTitle() - + "]: " + currentMetadata.getStreamUrl(), - currentMetadata.getServiceId(), - currentMetadata.getStreamUrl()); - ErrorUtil.createNotification(context, errorInfo); - } - - currentMetadata.getMaybeStreamInfo().ifPresent(info -> { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()); - } - if (previousInfo == null || !previousInfo.getUrl().equals(info.getUrl())) { - // only update with the new stream info if it has actually changed - updateMetadataWith(info); - } else if (previousAudioTrack == null - || tag.getMaybeAudioTrack() - .map(t -> t.getSelectedAudioStreamIndex() - != previousAudioTrack.getSelectedAudioStreamIndex()) - .orElse(false)) { - notifyAudioTrackUpdateToListeners(); - } - }); - }); - } - - @Override - public void onTracksChanged(@NonNull final Tracks tracks) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onTracksChanged(), " - + "track group size = " + tracks.getGroups().size()); - } - UIs.call(playerUi -> playerUi.onTextTracksChanged(tracks)); - } - - @Override - public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed - + "], pitch = [" + playbackParameters.pitch + "]"); - } - UIs.call(playerUi -> playerUi.onPlaybackParametersChanged(playbackParameters)); - } - - @Override - public void onPositionDiscontinuity(@NonNull final PositionInfo oldPosition, - @NonNull final PositionInfo newPosition, - @DiscontinuityReason final int discontinuityReason) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " - + "oldPositionIndex = [" + oldPosition.mediaItemIndex + "], " - + "oldPositionMs = [" + oldPosition.positionMs + "], " - + "newPositionIndex = [" + newPosition.mediaItemIndex + "], " - + "newPositionMs = [" + newPosition.positionMs + "], " - + "discontinuityReason = [" + discontinuityReason + "]"); - } - if (playQueue == null) { - return; - } - - // Refresh the playback if there is a transition to the next video - final int newIndex = newPosition.mediaItemIndex; - switch (discontinuityReason) { - case DISCONTINUITY_REASON_AUTO_TRANSITION: - case DISCONTINUITY_REASON_REMOVE: - // When player is in single repeat mode and a period transition occurs, - // we need to register a view count here since no metadata has changed - if (getRepeatMode() == REPEAT_MODE_ONE && newIndex == playQueue.getIndex()) { - registerStreamViewed(); - break; - } - case DISCONTINUITY_REASON_SEEK: - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); - } - if (isPrepared) { - saveStreamProgressState(); - } - case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: - case DISCONTINUITY_REASON_INTERNAL: - // Player index may be invalid when playback is blocked - if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { - saveStreamProgressStateCompleted(); // current stream has ended - playQueue.setIndex(newIndex); - } - break; - case DISCONTINUITY_REASON_SKIP: - break; // only makes Android Studio linter happy, as there are no ads - } - } - - @Override - public void onRenderedFirstFrame() { - UIs.call(PlayerUi::onRenderedFirstFrame); - } - - @Override - public void onCues(@NonNull final CueGroup cueGroup) { - UIs.call(playerUi -> playerUi.onCues(cueGroup.cues)); - } - - /** - * To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector} - * receives an {@code onPrepare()} call. This function allows restoring the default behavior - * that would happen if there was no playback preparer set, i.e. to just call - * {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the - * {@link MediaSessionConnector} file. - */ - public void onPrepare() { - if (!exoPlayerIsNull()) { - simpleExoPlayer.prepare(); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Errors - //////////////////////////////////////////////////////////////////////////*/ - //region Errors - - /** - * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. - *

There are multiple types of errors:

- *
    - *
  • {@link PlaybackException#ERROR_CODE_BEHIND_LIVE_WINDOW BEHIND_LIVE_WINDOW}: - * If the playback on livestreams are lagged too far behind the current playable - * window. Then we seek to the latest timestamp and restart the playback. - * This error is catchable. - *
  • - *
  • From {@link PlaybackException#ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE BAD_IO} to - * {@link PlaybackException#ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED UNSUPPORTED_FORMATS}: - * If the stream source is validated by the extractor but not recognized by the player, - * then we can try to recover playback by signalling an error on the {@link PlayQueue}.
  • - *
  • For {@link PlaybackException#ERROR_CODE_TIMEOUT PLAYER_TIMEOUT}, - * {@link PlaybackException#ERROR_CODE_IO_UNSPECIFIED MEDIA_SOURCE_RESOLVER_TIMEOUT} and - * {@link PlaybackException#ERROR_CODE_IO_NETWORK_CONNECTION_FAILED NO_NETWORK}: - * We can keep set the recovery record and keep to player at the current state until - * it is ready to play by restarting the {@link MediaSourceManager}.
  • - *
  • On any ExoPlayer specific issue internal to its device interaction, such as - * {@link PlaybackException#ERROR_CODE_DECODER_INIT_FAILED DECODER_ERROR}: - * We terminate the playback.
  • - *
  • For any other unspecified issue internal: We set a recovery and try to restart - * the playback.
  • - * For any error above that is not explicitly catchable, the player will - * create a notification so users are aware. - *
- * - * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException) - */ - // Any error code not explicitly covered here are either unrelated to NewPipe use case - // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should - // shutdown. - @SuppressWarnings("SwitchIntDef") - @Override - public void onPlayerError(@NonNull final PlaybackException error) { - Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error); - - saveStreamProgressState(); - boolean isCatchableException = false; - - switch (error.errorCode) { - case ERROR_CODE_BEHIND_LIVE_WINDOW: - isCatchableException = true; - simpleExoPlayer.seekToDefaultPosition(); - simpleExoPlayer.prepare(); - // Inform the user that we are reloading the stream by - // switching to the buffering state - onBuffering(); - break; - case ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE: - case ERROR_CODE_IO_BAD_HTTP_STATUS: - case ERROR_CODE_IO_FILE_NOT_FOUND: - case ERROR_CODE_IO_NO_PERMISSION: - case ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED: - case ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE: - case ERROR_CODE_PARSING_CONTAINER_MALFORMED: - case ERROR_CODE_PARSING_MANIFEST_MALFORMED: - case ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED: - case ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED: - // Source errors, signal on playQueue and move on: - if (!exoPlayerIsNull() && playQueue != null) { - playQueue.error(); - } - break; - case ERROR_CODE_TIMEOUT: - case ERROR_CODE_IO_UNSPECIFIED: - case ERROR_CODE_IO_NETWORK_CONNECTION_FAILED: - case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT: - case ERROR_CODE_UNSPECIFIED: - // Reload playback on unexpected errors: - setRecovery(); - reloadPlayQueueManager(); - break; - default: - // API, remote and renderer errors belong here: - onPlaybackShutdown(); - break; - } - - if (!isCatchableException) { - createErrorNotification(error); - } - - if (fragmentListener != null) { - fragmentListener.onPlayerError(error, isCatchableException); - } - } - - private void createErrorNotification(@NonNull final PlaybackException error) { - final ErrorInfo errorInfo; - if (currentMetadata == null) { - errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, - "Player error[type=" + error.getErrorCodeName() - + "] occurred, currentMetadata is null"); - } else { - errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, - "Player error[type=" + error.getErrorCodeName() - + "] occurred while playing " + currentMetadata.getStreamUrl(), - currentMetadata.getServiceId(), currentMetadata.getStreamUrl()); - } - ErrorUtil.createNotification(context, errorInfo); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Playback position and seek - //////////////////////////////////////////////////////////////////////////*/ - //region Playback position and seek - - @Override // own playback listener (this is a getter) - public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { - // If live, then not near playback edge - // If not playing, then not approaching playback edge - if (exoPlayerIsNull() || isLive() || !isPlaying()) { - return false; - } - - final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); - final long currentDurationMillis = simpleExoPlayer.getDuration(); - return currentDurationMillis - currentPositionMillis < timeToEndMillis; - } - - /** - * Checks if the current playback is a livestream AND is playing at or beyond the live edge. - * - * @return whether the livestream is playing at or beyond the edge - */ - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean isLiveEdge() { - if (exoPlayerIsNull() || !isLive()) { - return false; - } - - final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); - final int currentWindowIndex = simpleExoPlayer.getCurrentMediaItemIndex(); - if (currentTimeline.isEmpty() || currentWindowIndex < 0 - || currentWindowIndex >= currentTimeline.getWindowCount()) { - return false; - } - - final Timeline.Window timelineWindow = new Timeline.Window(); - currentTimeline.getWindow(currentWindowIndex, timelineWindow); - return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition(); - } - - @Override // own playback listener - public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boolean wasBlocked) { - if (DEBUG) { - Log.d(TAG, "Playback - onPlaybackSynchronize(was blocked: " + wasBlocked - + ") called with item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); - } - if (exoPlayerIsNull() || playQueue == null || currentItem == item) { - return; // nothing to synchronize - } - - final int playQueueIndex = playQueue.indexOf(item); - final int playlistIndex = simpleExoPlayer.getCurrentMediaItemIndex(); - final int playlistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); - final boolean removeThumbnailBeforeSync = currentItem == null - || currentItem.getServiceId() != item.getServiceId() - || !currentItem.getUrl().equals(item.getUrl()); - - currentItem = item; - - if (playQueueIndex != playQueue.getIndex()) { - // wrong window (this should be impossible, as this method is called with - // `item=playQueue.getItem()`, so the index of that item must be equal to `getIndex()`) - Log.e(TAG, "Playback - Play Queue may be not in sync: item index=[" - + playQueueIndex + "], " + "queue index=[" + playQueue.getIndex() + "]"); - - } else if ((playlistSize > 0 && playQueueIndex >= playlistSize) || playQueueIndex < 0) { - // the queue and the player's timeline are not in sync, since the play queue index - // points outside of the timeline - Log.e(TAG, "Playback - Trying to seek to invalid index=[" + playQueueIndex - + "] with playlist length=[" + playlistSize + "]"); - - } else if (wasBlocked || playlistIndex != playQueueIndex || !isPlaying()) { - // either the player needs to be unblocked, or the play queue index has just been - // changed and needs to be synchronized, or the player is not playing - if (DEBUG) { - Log.d(TAG, "Playback - Rewinding to correct index=[" + playQueueIndex + "], " - + "from=[" + playlistIndex + "], size=[" + playlistSize + "]."); - } - - if (removeThumbnailBeforeSync) { - // unset the current (now outdated) thumbnail to ensure it is not used during sync - onThumbnailLoaded(null); - } - - // sync the player index with the queue index, and seek to the correct position - if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { - simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition()); - playQueue.unsetRecovery(playQueueIndex); - } else { - simpleExoPlayer.seekToDefaultPosition(playQueueIndex); - } - } - } - - public void seekTo(final long positionMillis) { - if (DEBUG) { - Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); - } - if (!exoPlayerIsNull()) { - // prevent invalid positions when fast-forwarding/-rewinding - simpleExoPlayer.seekTo(MathUtils.clamp(positionMillis, 0, - simpleExoPlayer.getDuration())); - } - } - - private void seekBy(final long offsetMillis) { - if (DEBUG) { - Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]"); - } - seekTo(simpleExoPlayer.getCurrentPosition() + offsetMillis); - } - - public void seekToDefault() { - if (!exoPlayerIsNull()) { - simpleExoPlayer.seekToDefaultPosition(); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Player actions (play, pause, previous, fast-forward, ...) - //////////////////////////////////////////////////////////////////////////*/ - //region Player actions (play, pause, previous, fast-forward, ...) - - public void play() { - if (DEBUG) { - Log.d(TAG, "play() called"); - } - if (audioReactor == null || playQueue == null || exoPlayerIsNull()) { - return; - } - - if (!isMuted()) { - audioReactor.requestAudioFocus(); - } - - if (currentState == STATE_COMPLETED) { - if (playQueue.getIndex() == 0) { - seekToDefault(); - } else { - playQueue.setIndex(0); - } - } - - if (isStopped()) { - // Some phones suspend a paused player after 10 minutes. This causes the player to - // enter STATE_IDLE, causing playback to fail. So we try to recover from that here. - setRecovery(); - reloadPlayQueueManager(); - } - - simpleExoPlayer.play(); - saveStreamProgressState(); - } - - public void pause() { - if (DEBUG) { - Log.d(TAG, "pause() called"); - } - if (audioReactor == null || exoPlayerIsNull()) { - return; - } - - audioReactor.abandonAudioFocus(); - simpleExoPlayer.pause(); - saveStreamProgressState(); - } - - public void playPause() { - if (DEBUG) { - Log.d(TAG, "onPlayPause() called"); - } - - if (getPlayWhenReady() - // When state is completed (replay button is shown) then (re)play and do not pause - && currentState != STATE_COMPLETED) { - pause(); - } else { - play(); - } - } - - public void playPrevious() { - if (DEBUG) { - Log.d(TAG, "onPlayPrevious() called"); - } - if (exoPlayerIsNull() || playQueue == null) { - return; - } - - /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT_MILLIS milliseconds, - * restart current track. Also restart the track if the current track - * is the first in a queue.*/ - if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS - || playQueue.getIndex() == 0) { - seekToDefault(); - playQueue.offsetIndex(0); - } else { - saveStreamProgressState(); - playQueue.offsetIndex(-1); - } - triggerProgressUpdate(); - } - - public void playNext() { - if (DEBUG) { - Log.d(TAG, "onPlayNext() called"); - } - if (playQueue == null) { - return; - } - - saveStreamProgressState(); - playQueue.offsetIndex(+1); - triggerProgressUpdate(); - } - - public void fastForward() { - if (DEBUG) { - Log.d(TAG, "fastRewind() called"); - } - seekBy(retrieveSeekDurationFromPreferences(this)); - triggerProgressUpdate(); - } - - public void fastRewind() { - if (DEBUG) { - Log.d(TAG, "fastRewind() called"); - } - seekBy(-retrieveSeekDurationFromPreferences(this)); - triggerProgressUpdate(); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // StreamInfo history: views and progress - //////////////////////////////////////////////////////////////////////////*/ - //region StreamInfo history: views and progress - - private void registerStreamViewed() { - getCurrentStreamInfo().ifPresent(info -> databaseUpdateDisposable - .add(recordManager.onViewed(info).onErrorComplete().subscribe())); - } - - private void saveStreamProgressState(final long progressMillis) { - getCurrentStreamInfo().ifPresent(info -> { - if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - return; - } - if (DEBUG) { - Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis - + ", currentMetadata=[" + info.getName() + "]"); - } - - databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(e -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe()); - }); - } - - public void saveStreamProgressState() { - if (exoPlayerIsNull() || currentMetadata == null || playQueue == null - || playQueue.getIndex() != simpleExoPlayer.getCurrentMediaItemIndex()) { - // Make sure play queue and current window index are equal, to prevent saving state for - // the wrong stream on discontinuity (e.g. when the stream just changed but the - // playQueue index and currentMetadata still haven't updated) - return; - } - // Save current position. It will help to restore this position once a user - // wants to play prev or next stream from the queue - playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); - saveStreamProgressState(simpleExoPlayer.getCurrentPosition()); - } - - public void saveStreamProgressStateCompleted() { - // current stream has ended, so the progress is its duration (+1 to overcome rounding) - getCurrentStreamInfo().ifPresent(info -> - saveStreamProgressState((info.getDuration() + 1) * 1000)); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Metadata - //////////////////////////////////////////////////////////////////////////*/ - //region Metadata - - private void updateMetadataWith(@NonNull final StreamInfo info) { - if (DEBUG) { - Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); - } - if (exoPlayerIsNull()) { - return; - } - - maybeAutoQueueNextStream(info); - - loadCurrentThumbnail(info.getThumbnails()); - registerStreamViewed(); - - notifyMetadataUpdateToListeners(); - notifyAudioTrackUpdateToListeners(); - UIs.call(playerUi -> playerUi.onMetadataChanged(info)); - } - - @NonNull - public String getVideoUrl() { - return currentMetadata == null - ? context.getString(R.string.unknown_content) - : currentMetadata.getStreamUrl(); - } - - @NonNull - public String getVideoUrlAtCurrentTime() { - final long timeSeconds = simpleExoPlayer.getCurrentPosition() / 1000; - String videoUrl = getVideoUrl(); - if (!isLive() && timeSeconds >= 0 && currentMetadata != null - && currentMetadata.getServiceId() == YouTube.getServiceId()) { - // Timestamp doesn't make sense in a live stream so drop it - videoUrl += ("&t=" + timeSeconds); - } - return videoUrl; - } - - @NonNull - public String getVideoTitle() { - return currentMetadata == null - ? context.getString(R.string.unknown_content) - : currentMetadata.getTitle(); - } - - @NonNull - public String getUploaderName() { - return currentMetadata == null - ? context.getString(R.string.unknown_content) - : currentMetadata.getUploaderName(); - } - - @Nullable - public Bitmap getThumbnail() { - return currentThumbnail; - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Play queue, segments and streams - //////////////////////////////////////////////////////////////////////////*/ - //region Play queue, segments and streams - - private void maybeAutoQueueNextStream(@NonNull final StreamInfo info) { - if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1 - || getRepeatMode() != REPEAT_MODE_OFF - || !PlayerHelper.isAutoQueueEnabled(context)) { - return; - } - // auto queue when starting playback on the last item when not repeating - final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, - playQueue.getStreams()); - if (autoQueue != null) { - playQueue.append(autoQueue.getStreams()); - } - } - - public void selectQueueItem(final PlayQueueItem item) { - if (playQueue == null || exoPlayerIsNull()) { - return; - } - - final int index = playQueue.indexOf(item); - if (index == -1) { - return; - } - - if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentMediaItemIndex() == index) { - seekToDefault(); - } else { - saveStreamProgressState(); - } - playQueue.setIndex(index); - } - - @Override - public void onPlayQueueEdited() { - notifyPlaybackUpdateToListeners(); - UIs.call(PlayerUi::onPlayQueueEdited); - } - - @Override // own playback listener - @Nullable - public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { - if (audioPlayerSelected()) { - return audioResolver.resolve(info); - } - - if (isAudioOnly && videoResolver.getStreamSourceType().orElse( - SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) - == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) { - // If the current info has only video streams with audio and if the stream is played as - // audio, we need to use the audio resolver, otherwise the video stream will be played - // in background. - return audioResolver.resolve(info); - } - - // Even if the stream is played in background, we need to use the video resolver if the - // info played is separated video-only and audio-only streams; otherwise, if the audio - // resolver was called when the app was in background, the app will only stream audio when - // the user come back to the app and will never fetch the video stream. - // Note that the video is not fetched when the app is in background because the video - // renderer is fully disabled (see useVideoAndSubtitles method), except for HLS streams - // (see https://github.com/google/ExoPlayer/issues/9282). - return videoResolver.resolve(info); - } - - public void disablePreloadingOfCurrentTrack() { - loadController.disablePreloadingOfCurrentTrack(); - } - - public Optional getSelectedVideoStream() { - return Optional.ofNullable(currentMetadata) - .flatMap(MediaItemTag::getMaybeQuality) - .filter(quality -> { - final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); - return selectedStreamIndex >= 0 - && selectedStreamIndex < quality.getSortedVideoStreams().size(); - }) - .map(quality -> quality.getSortedVideoStreams() - .get(quality.getSelectedVideoStreamIndex())); - } - - public Optional getSelectedAudioStream() { - return Optional.ofNullable(currentMetadata) - .flatMap(MediaItemTag::getMaybeAudioTrack) - .map(MediaItemTag.AudioTrack::getSelectedAudioStream); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Captions (text tracks) - //////////////////////////////////////////////////////////////////////////*/ - //region Captions (text tracks) - - public int getCaptionRendererIndex() { - if (exoPlayerIsNull()) { - return RENDERER_UNAVAILABLE; - } - - for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) { - if (simpleExoPlayer.getRendererType(t) == C.TRACK_TYPE_TEXT) { - return t; - } - } - - return RENDERER_UNAVAILABLE; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Video size - //////////////////////////////////////////////////////////////////////////*/ - //region Video size - @Override // exoplayer listener - public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { - if (DEBUG) { - Log.d(TAG, "onVideoSizeChanged() called with: " - + "width / height = [" + videoSize.width + " / " + videoSize.height - + " = " + (((float) videoSize.width) / videoSize.height) + "], " - + "unappliedRotationDegrees = [" + videoSize.unappliedRotationDegrees + "], " - + "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]"); - } - - UIs.call(playerUi -> playerUi.onVideoSizeChanged(videoSize)); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Activity / fragment binding - //////////////////////////////////////////////////////////////////////////*/ - //region Activity / fragment binding - - public void setFragmentListener(final PlayerServiceEventListener listener) { - fragmentListener = listener; - UIs.call(PlayerUi::onFragmentListenerSet); - notifyQueueUpdateToListeners(); - notifyMetadataUpdateToListeners(); - notifyPlaybackUpdateToListeners(); - triggerProgressUpdate(); - } - - public void removeFragmentListener(final PlayerServiceEventListener listener) { - if (fragmentListener == listener) { - fragmentListener = null; - } - } - - void setActivityListener(final PlayerEventListener listener) { - activityListener = listener; - // TODO why not queue update? - notifyMetadataUpdateToListeners(); - notifyPlaybackUpdateToListeners(); - triggerProgressUpdate(); - } - - void removeActivityListener(final PlayerEventListener listener) { - if (activityListener == listener) { - activityListener = null; - } - } - - void stopActivityBinding() { - if (fragmentListener != null) { - fragmentListener.onServiceStopped(); - fragmentListener = null; - } - if (activityListener != null) { - activityListener.onServiceStopped(); - activityListener = null; - } - } - - private void notifyQueueUpdateToListeners() { - if (fragmentListener != null && playQueue != null) { - fragmentListener.onQueueUpdate(playQueue); - } - if (activityListener != null && playQueue != null) { - activityListener.onQueueUpdate(playQueue); - } - } - - private void notifyMetadataUpdateToListeners() { - getCurrentStreamInfo().ifPresent(info -> { - if (fragmentListener != null) { - fragmentListener.onMetadataUpdate(info, playQueue); - } - if (activityListener != null) { - activityListener.onMetadataUpdate(info, playQueue); - } - }); - } - - private void notifyPlaybackUpdateToListeners() { - if (fragmentListener != null && !exoPlayerIsNull() && playQueue != null) { - fragmentListener.onPlaybackUpdate(currentState, getRepeatMode(), - playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); - } - if (activityListener != null && !exoPlayerIsNull() && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), - playQueue.isShuffled(), getPlaybackParameters()); - } - } - - private void notifyProgressUpdateToListeners(final int currentProgress, - final int duration, - final int bufferPercent) { - if (fragmentListener != null) { - fragmentListener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - if (activityListener != null) { - activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - } - - private void notifyAudioTrackUpdateToListeners() { - if (fragmentListener != null) { - fragmentListener.onAudioTrackUpdate(); - } - if (activityListener != null) { - activityListener.onAudioTrackUpdate(); - } - } - - public void useVideoAndSubtitles(final boolean videoAndSubtitlesEnabled) { - if (playQueue == null) { - return; - } - - isAudioOnly = !videoAndSubtitlesEnabled; - - final var item = playQueue.getItem(); - final boolean hasPendingRecovery = - item != null && item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET; - final boolean hasTimeline = - !exoPlayerIsNull() && !simpleExoPlayer.getCurrentTimeline().isEmpty(); - - - getCurrentStreamInfo().ifPresentOrElse(info -> { - // In case we don't know the source type, fall back to either video-with-audio, or - // audio-only source type - final SourceType sourceType = videoResolver.getStreamSourceType() - .orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); - - if (hasTimeline || !hasPendingRecovery) { - // making sure to save playback position before reloadPlayQueueManager() - setRecovery(); - } - - if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { - reloadPlayQueueManager(); - } - }, () -> { - /* - The current metadata may be null sometimes (for e.g. when using an unstable connection - in livestreams) so we will be not able to execute the block above - - Reload the play queue manager in this case, which is the behavior when we don't know the - index of the video renderer or playQueueManagerReloadingNeeded returns true - */ - if (hasTimeline || !hasPendingRecovery) { - // making sure to save playback position before reloadPlayQueueManager() - setRecovery(); - } - reloadPlayQueueManager(); - }); - - // Disable or enable video and subtitles renderers depending of the - // videoAndSubtitlesEnabled value - trackSelector.setParameters(trackSelector.buildUponParameters() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoAndSubtitlesEnabled) - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoAndSubtitlesEnabled)); - } - - /** - * Return whether the play queue manager needs to be reloaded when switching player type. - * - *

- * The play queue manager needs to be reloaded if the video renderer index is not known and if - * the content is not an audio content, but also if none of the following cases is met: - * - *

    - *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream}, an - * {@link StreamType#AUDIO_LIVE_STREAM audio live stream}, or a - * {@link StreamType#POST_LIVE_AUDIO_STREAM ended audio live stream};
  • - *
  • the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a - * {@link SourceType#LIVE_STREAM live source};
  • - *
  • the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream - * with a separated audio source} or has no audio-only streams available and is a - * {@link StreamType#VIDEO_STREAM video stream}, an - * {@link StreamType#POST_LIVE_STREAM ended live stream}, or a - * {@link StreamType#LIVE_STREAM live stream}. - *
  • - *
- *

- * - * @param sourceType the {@link SourceType} of the stream - * @param streamInfo the {@link StreamInfo} of the stream - * @param videoRendererIndex the video renderer index of the video source, if that's a video - * source (or {@link #RENDERER_UNAVAILABLE}) - * @return whether the play queue manager needs to be reloaded - */ - private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, - @NonNull final StreamInfo streamInfo, - final int videoRendererIndex) { - final StreamType streamType = streamInfo.getStreamType(); - final boolean isStreamTypeAudio = StreamTypeUtil.isAudio(streamType); - - if (videoRendererIndex == RENDERER_UNAVAILABLE && !isStreamTypeAudio) { - return true; - } - - // The content is an audio stream, an audio live stream, or a live stream with a live - // source: it's not needed to reload the play queue manager because the stream source will - // be the same - if (isStreamTypeAudio || (streamType == StreamType.LIVE_STREAM - && sourceType == SourceType.LIVE_STREAM)) { - return false; - } - - // The content's source is a video with separated audio or a video with audio -> the video - // and its fetch may be disabled - // The content's source is a video with embedded audio and the content has no separated - // audio stream available: it's probably not needed to reload the play queue manager - // because the stream source will be probably the same as the current played - if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO - || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY - && isNullOrEmpty(streamInfo.getAudioStreams()))) { - // It's not needed to reload the play queue manager only if the content's stream type - // is a video stream, a live stream or an ended live stream - return !StreamTypeUtil.isVideo(streamType); - } - - // Other cases: the play queue manager reload is needed - return true; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Getters - //////////////////////////////////////////////////////////////////////////*/ - //region Getters - - public Optional getCurrentStreamInfo() { - return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo); - } - - public int getCurrentState() { - return currentState; - } - - public boolean exoPlayerIsNull() { - return simpleExoPlayer == null; - } - - public ExoPlayer getExoPlayer() { - return simpleExoPlayer; - } - - public boolean isStopped() { - return exoPlayerIsNull() || simpleExoPlayer.getPlaybackState() == ExoPlayer.STATE_IDLE; - } - - public boolean isPlaying() { - return !exoPlayerIsNull() && simpleExoPlayer.isPlaying(); - } - - public boolean getPlayWhenReady() { - return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady(); - } - - public boolean isLoading() { - return !exoPlayerIsNull() && simpleExoPlayer.isLoading(); - } - - private boolean isLive() { - try { - return !exoPlayerIsNull() && simpleExoPlayer.isCurrentMediaItemDynamic(); - } catch (final IndexOutOfBoundsException e) { - // Why would this even happen =(... but lets log it anyway, better safe than sorry - if (DEBUG) { - Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e); - } - return false; - } - } - - public void setPlaybackQuality(@Nullable final String quality) { - saveStreamProgressState(); - setRecovery(); - videoResolver.setPlaybackQuality(quality); - reloadPlayQueueManager(); - } - - public void setAudioTrack(@Nullable final String audioTrackId) { - saveStreamProgressState(); - setRecovery(); - videoResolver.setAudioTrack(audioTrackId); - audioResolver.setAudioTrack(audioTrackId); - reloadPlayQueueManager(); - } - - - @NonNull - public Context getContext() { - return context; - } - - @NonNull - public SharedPreferences getPrefs() { - return prefs; - } - - - public PlayerType getPlayerType() { - return playerType; - } - - public boolean audioPlayerSelected() { - return playerType == PlayerType.AUDIO; - } - - public boolean videoPlayerSelected() { - return playerType == PlayerType.MAIN; - } - - public boolean popupPlayerSelected() { - return playerType == PlayerType.POPUP; - } - - - @Nullable - public PlayQueue getPlayQueue() { - return playQueue; - } - - public AudioReactor getAudioReactor() { - return audioReactor; - } - - public PlayerService getService() { - return service; - } - - public boolean isAudioOnly() { - return isAudioOnly; - } - - @NonNull - public DefaultTrackSelector getTrackSelector() { - return trackSelector; - } - - @Nullable - public MediaItemTag getCurrentMetadata() { - return currentMetadata; - } - - @Nullable - public PlayQueueItem getCurrentItem() { - return currentItem; - } - - public Optional getFragmentListener() { - return Optional.ofNullable(fragmentListener); - } - - /** - * @return the user interfaces connected with the player - */ - @SuppressWarnings("MethodName") // keep the unusual method name - public PlayerUiList UIs() { - return UIs; - } - - /** - * Get the video renderer index of the current playing stream. - *

- * This method returns the video renderer index of the current - * {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current - * {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index. - * - * @return the video renderer index or {@link #RENDERER_UNAVAILABLE} if it cannot be get - */ - private int getVideoRendererIndex() { - final MappingTrackSelector.MappedTrackInfo mappedTrackInfo = trackSelector - .getCurrentMappedTrackInfo(); - - if (mappedTrackInfo == null) { - return RENDERER_UNAVAILABLE; - } - - // Check every renderer - return IntStream.range(0, mappedTrackInfo.getRendererCount()) - // Check the renderer is a video renderer and has at least one track - .filter(i -> !mappedTrackInfo.getTrackGroups(i).isEmpty() - && simpleExoPlayer.getRendererType(i) == C.TRACK_TYPE_VIDEO) - // Return the first index found (there is at most one renderer per renderer type) - .findFirst() - // No video renderer index with at least one track found: return unavailable index - .orElse(RENDERER_UNAVAILABLE); - } - //endregion - - /** - * @return whether the device screen is turned on. - */ - public boolean isScreenOn() { - return screenOn; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt deleted file mode 100644 index ed0c19c99..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -// We model this as an enum class plus one struct for each enum value -// so we can consume it from Java properly. After converting to Kotlin, -// we could switch to a sealed enum class & a proper Kotlin `when` match. -enum class PlayerIntentType { - Enqueue, - EnqueueNext, - TimestampChange, - AllOthers -} - -/** - * A timestamp on the given was clicked and we should switch the playing stream to it. - */ -@Parcelize -data class TimestampChangeData( - val serviceId: Int, - val url: String, - val seconds: Int -) : Parcelable diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index dba30f9e8..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * Part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.player; - -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import android.support.v4.media.MediaBrowserCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ServiceCompat; -import androidx.media.MediaBrowserServiceCompat; - -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.ktx.BundleKt; -import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl; -import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer; -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.player.notification.NotificationUtil; -import org.schabi.newpipe.util.ThemeHelper; - -import java.lang.ref.WeakReference; -import java.util.List; -import java.util.function.Consumer; - - -/** - * One service for all players. - */ -public final class PlayerService extends MediaBrowserServiceCompat { - private static final String TAG = PlayerService.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - - public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra"; - public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action"; - - // These objects are used to cleanly separate the Service implementation (in this file) and the - // media browser and playback preparer implementations. At the moment the playback preparer is - // only used in conjunction with the media browser. - private MediaBrowserImpl mediaBrowserImpl; - private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer; - - // these are instantiated in onCreate() as per - // https://developer.android.com/training/cars/media#browser_workflow - private MediaSessionCompat mediaSession; - private MediaSessionConnector sessionConnector; - - @Nullable - private Player player; - - private final IBinder mBinder = new PlayerService.LocalBinder(this); - - /** - * The parameter taken by this {@link Consumer} can be null to indicate the player is being - * stopped. - */ - @Nullable - private Consumer onPlayerStartedOrStopped = null; - - - //region Service lifecycle - @Override - public void onCreate() { - super.onCreate(); - - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - ThemeHelper.setTheme(this); - - mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged); - - // see https://developer.android.com/training/cars/media#browser_workflow - mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ"); - setSessionToken(mediaSession.getSessionToken()); - sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setMetadataDeduplicationEnabled(true); - - mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer( - this, - sessionConnector::setCustomErrorMessage, - () -> sessionConnector.setCustomErrorMessage(null), - (playWhenReady) -> { - if (player != null) { - player.onPrepare(); - } - } - ); - sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer); - - // Note: you might be tempted to create the player instance and call startForeground here, - // but be aware that the Android system might start the service just to perform media - // queries. In those cases creating a player instance is a waste of resources, and calling - // startForeground means creating a useless empty notification. In case it's really needed - // the player instance can be created here, but startForeground() should definitely not be - // called here unless the service is actually starting in the foreground, to avoid the - // useless notification. - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], extras = [" + BundleKt.toDebugString(intent.getExtras()) - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - - // All internal NewPipe intents used to interact with the player, that are sent to the - // PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA, - // to ensure startForeground() is called (otherwise Android will force-crash the app). - if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) { - final boolean playerWasNull = (player == null); - if (playerWasNull) { - // make sure the player exists, in case the service was resumed - player = new Player(this, mediaSession, sessionConnector); - } - - // Be sure that the player notification is set and the service is started in foreground, - // otherwise, the app may crash on Android 8+ as the service would never be put in the - // foreground while we said to the system we would do so. The service is always - // requested to be started in foreground, so always creating a notification if there is - // no one already and starting the service in foreground should not create any issues. - // If the service is already started in foreground, requesting it to be started - // shouldn't do anything. - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - - if (playerWasNull && onPlayerStartedOrStopped != null) { - // notify that a new player was created (but do it after creating the foreground - // notification just to make sure we don't incur, due to slowness, in - // "Context.startForegroundService() did not then call Service.startForeground()") - onPlayerStartedOrStopped.accept(player); - } - } - - if (player == null) { - // No need to process media button's actions or other system intents if the player is - // not running. However, since the current intent might have been issued by the system - // with `startForegroundService()` (for unknown reasons), we need to ensure that we post - // a (dummy) foreground notification, otherwise we'd incur in - // "Context.startForegroundService() did not then call Service.startForeground()". Then - // we stop the service again. - Log.d(TAG, "onStartCommand() got a useless intent, closing the service"); - NotificationUtil.startForegroundWithDummyNotification(this); - destroyPlayerAndStopService(); - return START_NOT_STICKY; - } - - final PlayerType oldPlayerType = player.getPlayerType(); - player.handleIntent(intent); - player.handleIntentPost(oldPlayerType); - player.UIs().get(MediaSessionPlayerUi.class) - .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); - - return START_NOT_STICKY; - } - - public void stopForImmediateReusing() { - if (DEBUG) { - Log.d(TAG, "stopForImmediateReusing() called"); - } - - if (player != null && !player.exoPlayerIsNull()) { - // Releases wifi & cpu, disables keepScreenOn, etc. - // We can't just pause the player here because it will make transition - // from one stream to a new stream not smooth - player.smoothStopForImmediateReusing(); - } - } - - @Override - public void onTaskRemoved(final Intent rootIntent) { - super.onTaskRemoved(rootIntent); - if (player != null && !player.videoPlayerSelected()) { - return; - } - onDestroy(); - // Unload from memory completely - Runtime.getRuntime().halt(0); - } - - @Override - public void onDestroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - super.onDestroy(); - - cleanup(); - - mediaBrowserPlaybackPreparer.dispose(); - mediaSession.release(); - mediaBrowserImpl.dispose(); - } - - private void cleanup() { - if (player != null) { - if (onPlayerStartedOrStopped != null) { - // notify that the player is being destroyed - onPlayerStartedOrStopped.accept(null); - } - player.destroy(); - player = null; - } - - // Should already be handled by MediaSessionPlayerUi, but just to be sure. - mediaSession.setActive(false); - - // Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in - // NotificationPlayerUi, but let's make sure that the foreground service is stopped. - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - } - - /** - * Destroys the player and allows the player instance to be garbage collected. Sets the media - * session to inactive. Stops the foreground service and removes the player notification - * associated with it. Tries to stop the {@link PlayerService} completely, but this step will - * have no effect in case some service connection still uses the service (e.g. the Android Auto - * system accesses the media browser even when no player is running). - */ - public void destroyPlayerAndStopService() { - if (DEBUG) { - Log.d(TAG, "destroyPlayerAndStopService() called"); - } - - cleanup(); - - // This only really stops the service if there are no other service connections (see docs): - // for example the (Android Auto) media browser binder will block stopService(). - // This is why we also stopForeground() above, to make sure the notification is removed. - // If we were to call stopSelf(), then the service would be surely stopped (regardless of - // other service connections), but this would be a waste of resources since the service - // would be immediately restarted by those same connections to perform the queries. - stopService(new Intent(this, PlayerService.class)); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - //endregion - - //region Bind - @Override - public IBinder onBind(final Intent intent) { - if (DEBUG) { - Log.d(TAG, "onBind() called with: intent = [" + intent - + "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]"); - } - - if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) { - // Note that this binder might be reused multiple times while the service is alive, even - // after unbind() has been called: https://stackoverflow.com/a/8794930 . - return mBinder; - - } else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) { - // MediaBrowserService also uses its own binder, so for actions related to the media - // browser service, pass the onBind to the superclass. - return super.onBind(intent); - - } else { - // This is an unknown request, avoid returning any binder to not leak objects. - return null; - } - } - - public static class LocalBinder extends Binder { - private final WeakReference playerService; - - LocalBinder(final PlayerService playerService) { - this.playerService = new WeakReference<>(playerService); - } - - public PlayerService getService() { - return playerService.get(); - } - } - - /** - * @return the current active player instance. May be null, since the player service can outlive - * the player e.g. to respond to Android Auto media browser queries. - */ - @Nullable - public Player getPlayer() { - return player; - } - - /** - * Sets the listener that will be called when the player is started or stopped. If a - * {@code null} listener is passed, then the current listener will be unset. The parameter taken - * by the {@link Consumer} can be null to indicate that the player is stopping. - * @param listener the listener to set or unset - */ - public void setPlayerListener(@Nullable final Consumer listener) { - this.onPlayerStartedOrStopped = listener; - if (listener != null) { - // if there is no player, then `null` will be sent here, to ensure the state is synced - listener.accept(player); - } - } - //endregion - - //region Media browser - @Override - public BrowserRoot onGetRoot(@NonNull final String clientPackageName, - final int clientUid, - @Nullable final Bundle rootHints) { - return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints); - } - - @Override - public void onLoadChildren(@NonNull final String parentId, - @NonNull final Result> result) { - mediaBrowserImpl.onLoadChildren(parentId, result); - } - - @Override - public void onSearch(@NonNull final String query, - final Bundle extras, - @NonNull final Result> result) { - mediaBrowserImpl.onSearch(query, result); - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt deleted file mode 100644 index 42b2e1131..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.player - -enum class PlayerType { - MAIN, - AUDIO, - POPUP -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java deleted file mode 100644 index 676443a9c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java +++ /dev/null @@ -1,136 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; -import com.google.android.exoplayer2.upstream.ByteArrayDataSource; -import com.google.android.exoplayer2.upstream.DataSource; - -import java.nio.charset.StandardCharsets; - -/** - * A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for - * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s. - * - *

- * If media requests are relative, the URI from which the manifest comes from (either the - * manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the - * content will be not playable, as it will be an invalid URL, or it may be treat as something - * unexpected, for instance as a file for - * {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s. - *

- * - *

- * See {@link #createDataSource(int)} for changes and implementation details. - *

- */ -public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory { - - /** - * Builder class of {@link NonUriHlsDataSourceFactory} instances. - */ - public static final class Builder { - private DataSource.Factory dataSourceFactory; - private String playlistString; - - /** - * Set the {@link DataSource.Factory} which will be used to create non manifest contents - * {@link DataSource}s. - * - * @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will - * be used to create non manifest contents - * {@link DataSource}s, which cannot be null - */ - public void setDataSourceFactory( - @NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) { - this.dataSourceFactory = dataSourceFactoryForNonManifestContents; - } - - /** - * Set the HLS playlist which will be used for manifests requests. - * - * @param hlsPlaylistString the string which correspond to the response of the HLS - * manifest, which cannot be null or empty - */ - public void setPlaylistString(@NonNull final String hlsPlaylistString) { - this.playlistString = hlsPlaylistString; - } - - /** - * Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and - * the given HLS playlist. - * - * @return a {@link NonUriHlsDataSourceFactory} - * @throws IllegalArgumentException if the data source factory is null or if the HLS - * playlist string set is null or empty - */ - @NonNull - public NonUriHlsDataSourceFactory build() { - if (dataSourceFactory == null) { - throw new IllegalArgumentException( - "No DataSource.Factory valid instance has been specified."); - } - - if (isNullOrEmpty(playlistString)) { - throw new IllegalArgumentException("No HLS valid playlist has been specified."); - } - - return new NonUriHlsDataSourceFactory(dataSourceFactory, - playlistString.getBytes(StandardCharsets.UTF_8)); - } - } - - private final DataSource.Factory dataSourceFactory; - private final byte[] playlistStringByteArray; - - /** - * Create a {@link NonUriHlsDataSourceFactory} instance. - * - * @param dataSourceFactory the {@link DataSource.Factory} which will be used to build - * non manifests {@link DataSource}s, which must not be null - * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null - */ - private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory, - @NonNull final byte[] playlistStringByteArray) { - this.dataSourceFactory = dataSourceFactory; - this.playlistStringByteArray = playlistStringByteArray; - } - - /** - * Create a {@link DataSource} for the given data type. - * - *

- * Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory - * ExoPlayer's default implementation}, this implementation is not always using the - * {@link DataSource.Factory} passed to the - * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory - * HlsMediaSource.Factory} constructor, only when it's not - * {@link C#DATA_TYPE_MANIFEST the manifest type}. - *

- * - *

- * This change allow playback of non-URI HLS contents, when the manifest is not a master - * manifest/playlist (otherwise, endless loops should be encountered because the - * {@link DataSource}s created for media playlists should use the master playlist response - * instead). - *

- * - * @param dataType the data type for which the {@link DataSource} will be used, which is one of - * {@link C} {@code .DATA_TYPE_*} constants - * @return a {@link DataSource} for the given data type - */ - @NonNull - @Override - public DataSource createDataSource(final int dataType) { - // The manifest is already downloaded and provided with playlistStringByteArray, so we - // don't need to download it again and we can use a ByteArrayDataSource instead - if (dataType == C.DATA_TYPE_MANIFEST) { - return new ByteArrayDataSource(playlistStringByteArray); - } - - return dataSourceFactory.createDataSource(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java deleted file mode 100644 index 4cdb649a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java +++ /dev/null @@ -1,1009 +0,0 @@ -/* - * Based on ExoPlayer's DefaultHttpDataSource, version 2.18.1. - * - * Original source code copyright (C) 2016 The Android Open Source Project, licensed under the - * Apache License, Version 2.0. - */ - -package org.schabi.newpipe.player.datasource; - -import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; -import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; -import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Util.castNonNull; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl; -import static java.lang.Math.min; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.upstream.BaseDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSourceException; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.HttpUtil; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; -import com.google.common.base.Predicate; -import com.google.common.collect.ForwardingMap; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Sets; -import com.google.common.net.HttpHeaders; - -import org.schabi.newpipe.DownloaderImpl; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InterruptedIOException; -import java.io.OutputStream; -import java.lang.reflect.Method; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.NoRouteToHostException; -import java.net.URL; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.zip.GZIPInputStream; - -/** - * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on - * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. - * - *

- * It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} - * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of - * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. - *

- * - * There are many unused methods in this class because everything was copied from {@link - * com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible. - * SonarQube warnings were also suppressed for the same reason. - */ -@SuppressWarnings({"squid:S3011", "squid:S4738"}) -public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { - - /** - * {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances. - */ - public static final class Factory implements HttpDataSource.Factory { - - private final RequestProperties defaultRequestProperties; - - @Nullable - private TransferListener transferListener; - @Nullable - private Predicate contentTypePredicate; - private int connectTimeoutMs; - private int readTimeoutMs; - private boolean allowCrossProtocolRedirects; - private boolean keepPostFor302Redirects; - - private boolean rangeParameterEnabled; - private boolean rnParameterEnabled; - - /** - * Creates an instance. - */ - public Factory() { - defaultRequestProperties = new RequestProperties(); - connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; - readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; - } - - @NonNull - @Override - public Factory setDefaultRequestProperties( - @NonNull final Map defaultRequestPropertiesMap) { - defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap); - return this; - } - - /** - * Sets the connect timeout, in milliseconds. - * - *

- * The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. - *

- * - * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) { - connectTimeoutMs = connectTimeoutMsValue; - return this; - } - - /** - * Sets the read timeout, in milliseconds. - * - *

The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. - * - * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public Factory setReadTimeoutMs(final int readTimeoutMsValue) { - readTimeoutMs = readTimeoutMsValue; - return this; - } - - /** - * Sets whether to allow cross protocol redirects. - * - *

The default is {@code false}. - * - * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. - * @return This factory. - */ - public Factory setAllowCrossProtocolRedirects( - final boolean allowCrossProtocolRedirectsValue) { - allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue; - return this; - } - - /** - * Sets whether the use of the {@code range} parameter instead of the {@code Range} header - * to request ranges of streams is enabled. - * - *

- * Note that it must be not enabled on streams which are using a {@link - * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback - * for them (some exceptions may be thrown). - *

- * - * @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead - * of the {@code Range} header (must be only enabled when - * non-{@code ProgressiveMediaSource}s) - * @return This factory. - */ - public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) { - rangeParameterEnabled = rangeParameterEnabledValue; - return this; - } - - /** - * Sets whether the use of the {@code rn}, which stands for request number, parameter is - * enabled. - * - *

- * Note that it should be not enabled on streams which are using {@code /} to delimit URLs - * parameters, such as the streams of HLS manifests. - *

- * - * @param rnParameterEnabledValue whether the appending the {@code rn} parameter to - * {@code videoplayback} URLs - * @return This factory. - */ - public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) { - rnParameterEnabled = rnParameterEnabledValue; - return this; - } - - /** - * Sets a content type {@link Predicate}. If a content type is rejected by the predicate - * then a {@link HttpDataSource.InvalidContentTypeException} is thrown from - * {@link YoutubeHttpDataSource#open(DataSpec)}. - * - *

- * The default is {@code null}. - *

- * - * @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to - * clear a predicate that was previously set. - * @return This factory. - */ - public Factory setContentTypePredicate( - @Nullable final Predicate contentTypePredicateToSet) { - this.contentTypePredicate = contentTypePredicateToSet; - return this; - } - - /** - * Sets the {@link TransferListener} that will be used. - * - *

The default is {@code null}. - * - *

See {@link DataSource#addTransferListener(TransferListener)}. - * - * @param transferListenerToUse The listener that will be used. - * @return This factory. - */ - public Factory setTransferListener( - @Nullable final TransferListener transferListenerToUse) { - this.transferListener = transferListenerToUse; - return this; - } - - /** - * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for - * a POST request. - * - * @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when - * we have HTTP 302 redirects for a POST request. - * @return This factory. - */ - public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) { - this.keepPostFor302Redirects = keepPostFor302RedirectsValue; - return this; - } - - @NonNull - @Override - public YoutubeHttpDataSource createDataSource() { - final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( - connectTimeoutMs, - readTimeoutMs, - allowCrossProtocolRedirects, - rangeParameterEnabled, - rnParameterEnabled, - defaultRequestProperties, - contentTypePredicate, - keepPostFor302Redirects); - if (transferListener != null) { - dataSource.addTransferListener(transferListener); - } - return dataSource; - } - } - - private static final String TAG = YoutubeHttpDataSource.class.getSimpleName(); - private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. - private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; - private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; - private static final long MAX_BYTES_TO_DRAIN = 2048; - - private static final String RN_PARAMETER = "&rn="; - private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; - private static final byte[] POST_BODY = new byte[] {0x78, 0}; - - private final boolean allowCrossProtocolRedirects; - private final boolean rangeParameterEnabled; - private final boolean rnParameterEnabled; - - private final int connectTimeoutMillis; - private final int readTimeoutMillis; - @Nullable - private final RequestProperties defaultRequestProperties; - private final RequestProperties requestProperties; - private final boolean keepPostFor302Redirects; - - @Nullable - private final Predicate contentTypePredicate; - @Nullable - private DataSpec dataSpec; - @Nullable - private HttpURLConnection connection; - @Nullable - private InputStream inputStream; - private boolean opened; - private int responseCode; - private long bytesToRead; - private long bytesRead; - - private long requestNumber; - - @SuppressWarnings("checkstyle:ParameterNumber") - private YoutubeHttpDataSource(final int connectTimeoutMillis, - final int readTimeoutMillis, - final boolean allowCrossProtocolRedirects, - final boolean rangeParameterEnabled, - final boolean rnParameterEnabled, - @Nullable final RequestProperties defaultRequestProperties, - @Nullable final Predicate contentTypePredicate, - final boolean keepPostFor302Redirects) { - super(true); - this.connectTimeoutMillis = connectTimeoutMillis; - this.readTimeoutMillis = readTimeoutMillis; - this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; - this.rangeParameterEnabled = rangeParameterEnabled; - this.rnParameterEnabled = rnParameterEnabled; - this.defaultRequestProperties = defaultRequestProperties; - this.contentTypePredicate = contentTypePredicate; - this.requestProperties = new RequestProperties(); - this.keepPostFor302Redirects = keepPostFor302Redirects; - this.requestNumber = 0; - } - - @Override - @Nullable - public Uri getUri() { - return connection == null ? null : Uri.parse(connection.getURL().toString()); - } - - @Override - public int getResponseCode() { - return connection == null || responseCode <= 0 ? -1 : responseCode; - } - - @NonNull - @Override - public Map> getResponseHeaders() { - if (connection == null) { - return ImmutableMap.of(); - } - // connection.getHeaderFields() always contains a null key with a value like - // ["HTTP/1.1 200 OK"]. The response code is available from - // HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the - // connection. - // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need - // to remove it. - // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map - // so we can't just remove the null key or make a copy without the null key. Instead we - // wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read - // methods. - return new NullFilteringHeadersMap(connection.getHeaderFields()); - } - - @Override - public void setRequestProperty(@NonNull final String name, @NonNull final String value) { - checkNotNull(name); - checkNotNull(value); - requestProperties.set(name, value); - } - - @Override - public void clearRequestProperty(@NonNull final String name) { - checkNotNull(name); - requestProperties.remove(name); - } - - @Override - public void clearAllRequestProperties() { - requestProperties.clear(); - } - - /** - * Opens the source to read the specified data. - */ - @Override - public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException { - this.dataSpec = dataSpecParameter; - bytesRead = 0; - bytesToRead = 0; - transferInitializing(dataSpecParameter); - - final HttpURLConnection httpURLConnection; - final String responseMessage; - try { - this.connection = makeConnection(dataSpec); - httpURLConnection = this.connection; - responseCode = httpURLConnection.getResponseCode(); - responseMessage = httpURLConnection.getResponseMessage(); - } catch (final IOException e) { - closeConnectionQuietly(); - throw HttpDataSourceException.createForIOException(e, dataSpec, - HttpDataSourceException.TYPE_OPEN); - } - - // Check for a valid response code. - if (responseCode < 200 || responseCode > 299) { - final Map> headers = httpURLConnection.getHeaderFields(); - if (responseCode == 416) { - final long documentSize = HttpUtil.getDocumentSize( - httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); - if (dataSpecParameter.position == documentSize) { - opened = true; - transferStarted(dataSpecParameter); - return dataSpecParameter.length != C.LENGTH_UNSET - ? dataSpecParameter.length - : 0; - } - } - - final InputStream errorStream = httpURLConnection.getErrorStream(); - byte[] errorResponseBody; - try { - errorResponseBody = errorStream != null - ? Util.toByteArray(errorStream) - : Util.EMPTY_BYTE_ARRAY; - } catch (final IOException e) { - errorResponseBody = Util.EMPTY_BYTE_ARRAY; - } - - closeConnectionQuietly(); - final IOException cause = responseCode == 416 ? new DataSourceException( - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) - : null; - throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers, - dataSpec, errorResponseBody); - } - - // Check for a valid content type. - final String contentType = httpURLConnection.getContentType(); - if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { - closeConnectionQuietly(); - throw new InvalidContentTypeException(contentType, dataSpecParameter); - } - - final long bytesToSkip; - if (!rangeParameterEnabled) { - // If we requested a range starting from a non-zero position and received a 200 rather - // than a 206, then the server does not support partial requests. We'll need to - // manually skip to the requested position. - bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0 - ? dataSpecParameter.position - : 0; - } else { - bytesToSkip = 0; - } - - - // Determine the length of the data to be read, after skipping. - final boolean isCompressed = isCompressed(httpURLConnection); - if (!isCompressed) { - if (dataSpecParameter.length != C.LENGTH_UNSET) { - bytesToRead = dataSpecParameter.length; - } else { - final long contentLength = HttpUtil.getContentLength( - httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), - httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); - bytesToRead = contentLength != C.LENGTH_UNSET - ? (contentLength - bytesToSkip) - : C.LENGTH_UNSET; - } - } else { - // Gzip is enabled. If the server opts to use gzip then the content length in the - // response will be that of the compressed data, which isn't what we want. Always use - // the dataSpec length in this case. - bytesToRead = dataSpecParameter.length; - } - - try { - inputStream = httpURLConnection.getInputStream(); - if (isCompressed) { - inputStream = new GZIPInputStream(inputStream); - } - } catch (final IOException e) { - closeConnectionQuietly(); - throw new HttpDataSourceException(e, dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - } - - opened = true; - transferStarted(dataSpecParameter); - - try { - skipFully(bytesToSkip, dataSpec); - } catch (final IOException e) { - closeConnectionQuietly(); - if (e instanceof HttpDataSourceException) { - throw (HttpDataSourceException) e; - } - throw new HttpDataSourceException(e, dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - } - - return bytesToRead; - } - - @Override - public int read(@NonNull final byte[] buffer, final int offset, final int length) - throws HttpDataSourceException { - try { - return readInternal(buffer, offset, length); - } catch (final IOException e) { - throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec), - HttpDataSourceException.TYPE_READ); - } - } - - @Override - public void close() throws HttpDataSourceException { - try { - final InputStream connectionInputStream = this.inputStream; - if (connectionInputStream != null) { - final long bytesRemaining = bytesToRead == C.LENGTH_UNSET - ? C.LENGTH_UNSET - : bytesToRead - bytesRead; - maybeTerminateInputStream(connection, bytesRemaining); - - try { - connectionInputStream.close(); - } catch (final IOException e) { - throw new HttpDataSourceException(e, castNonNull(dataSpec), - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_CLOSE); - } - } - } finally { - inputStream = null; - closeConnectionQuietly(); - if (opened) { - opened = false; - transferEnded(); - } - } - } - - @NonNull - private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse) - throws IOException { - URL url = new URL(dataSpecToUse.uri.toString()); - @HttpMethod int httpMethod = dataSpecToUse.httpMethod; - @Nullable byte[] httpBody = dataSpecToUse.httpBody; - final long position = dataSpecToUse.position; - final long length = dataSpecToUse.length; - final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); - - if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { - // HttpURLConnection disallows cross-protocol redirects, but otherwise performs - // redirection automatically. This is the behavior we want, so use it. - return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true, - dataSpecToUse.httpRequestHeaders); - } - - // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the - // POST request method for 302. - int redirectCount = 0; - while (redirectCount++ <= MAX_REDIRECTS) { - final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody, - position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders); - final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode(); - final String location = httpURLConnection.getHeaderField("Location"); - if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) - && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER - || httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT - || httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { - httpURLConnection.disconnect(); - url = handleRedirect(url, location, dataSpecToUse); - } else if (httpMethod == DataSpec.HTTP_METHOD_POST - && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP - || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) { - httpURLConnection.disconnect(); - final boolean shouldKeepPost = keepPostFor302Redirects - && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; - if (!shouldKeepPost) { - // POST request follows the redirect and is transformed into a GET request. - httpMethod = DataSpec.HTTP_METHOD_GET; - httpBody = null; - } - url = handleRedirect(url, location, dataSpecToUse); - } else { - return httpURLConnection; - } - } - - // If we get here we've been redirected more times than are permitted. - throw new HttpDataSourceException( - new NoRouteToHostException("Too many redirects: " + redirectCount), - dataSpecToUse, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - HttpDataSourceException.TYPE_OPEN); - } - - /** - * Configures a connection and opens it. - * - * @param url The url to connect to. - * @param httpMethod The http method. - * @param httpBody The body data, or {@code null} if not required. - * @param position The byte offset of the requested data. - * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. - * @param allowGzip Whether to allow the use of gzip. - * @param followRedirects Whether to follow redirects. - * @param requestParameters parameters (HTTP headers) to include in request. - * @return the connection opened - */ - @SuppressWarnings("checkstyle:ParameterNumber") - @NonNull - private HttpURLConnection makeConnection( - @NonNull final URL url, - @HttpMethod final int httpMethod, - @Nullable final byte[] httpBody, - final long position, - final long length, - final boolean allowGzip, - final boolean followRedirects, - final Map requestParameters) throws IOException { - // This is the method that contains breaking changes with respect to DefaultHttpDataSource! - - String requestUrl = url.toString(); - - // Don't add the request number parameter if it has been already added (for instance in - // DASH manifests) or if that's not a videoplayback URL - final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback"); - if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { - requestUrl += RN_PARAMETER + requestNumber; - ++requestNumber; - } - - if (rangeParameterEnabled && isVideoPlaybackUrl) { - final String rangeParameterBuilt = buildRangeParameter(position, length); - if (rangeParameterBuilt != null) { - requestUrl += rangeParameterBuilt; - } - } - - final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl)); - httpURLConnection.setConnectTimeout(connectTimeoutMillis); - httpURLConnection.setReadTimeout(readTimeoutMillis); - - final Map requestHeaders = new HashMap<>(); - if (defaultRequestProperties != null) { - requestHeaders.putAll(defaultRequestProperties.getSnapshot()); - } - requestHeaders.putAll(requestProperties.getSnapshot()); - requestHeaders.putAll(requestParameters); - - for (final Map.Entry property : requestHeaders.entrySet()) { - httpURLConnection.setRequestProperty(property.getKey(), property.getValue()); - } - - if (!rangeParameterEnabled) { - final String rangeHeader = buildRangeRequestHeader(position, length); - if (rangeHeader != null) { - httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader); - } - } - - if (isWebStreamingUrl(requestUrl) - || isWebEmbeddedPlayerStreamingUrl(requestUrl)) { - httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL); - httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL); - httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty"); - httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors"); - httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site"); - } - - httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); - - final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); - final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl); - if (isAndroidStreamingUrl) { - // Improvement which may be done: find the content country used to request YouTube - // contents to add it in the user agent instead of using the default - httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, - getAndroidUserAgent(null)); - } else if (isIosStreamingUrl) { - httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, - getIosUserAgent(null)); - } else { - // non-mobile user agent - httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); - } - - httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, - allowGzip ? "gzip" : "identity"); - httpURLConnection.setInstanceFollowRedirects(followRedirects); - // Most clients use POST requests to fetch contents - httpURLConnection.setRequestMethod("POST"); - httpURLConnection.setDoOutput(true); - httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length); - httpURLConnection.connect(); - - final OutputStream os = httpURLConnection.getOutputStream(); - os.write(POST_BODY); - os.close(); - - return httpURLConnection; - } - - /** - * Creates an {@link HttpURLConnection} that is connected with the {@code url}. - * - * @param url the {@link URL} to create an {@link HttpURLConnection} - * @return an {@link HttpURLConnection} created with the {@code url} - */ - private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { - return (HttpURLConnection) url.openConnection(); - } - - /** - * Handles a redirect. - * - * @param originalUrl The original URL. - * @param location The Location header in the response. May be {@code null}. - * @param dataSpecToHandleRedirect The {@link DataSpec}. - * @return The next URL. - * @throws HttpDataSourceException If redirection isn't possible. - */ - @NonNull - private URL handleRedirect(final URL originalUrl, - @Nullable final String location, - final DataSpec dataSpecToHandleRedirect) - throws HttpDataSourceException { - if (location == null) { - throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - HttpDataSourceException.TYPE_OPEN); - } - - // Form the new url. - final URL url; - try { - url = new URL(originalUrl, location); - } catch (final MalformedURLException e) { - throw new HttpDataSourceException(e, dataSpecToHandleRedirect, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - HttpDataSourceException.TYPE_OPEN); - } - - // Check that the protocol of the new url is supported. - final String protocol = url.getProtocol(); - if (!"https".equals(protocol) && !"http".equals(protocol)) { - throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol, - dataSpecToHandleRedirect, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - HttpDataSourceException.TYPE_OPEN); - } - - if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { - throw new HttpDataSourceException( - "Disallowed cross-protocol redirect (" - + originalUrl.getProtocol() - + " to " - + protocol - + ")", - dataSpecToHandleRedirect, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - HttpDataSourceException.TYPE_OPEN); - } - - return url; - } - - /** - * Attempts to skip the specified number of bytes in full. - * - * @param bytesToSkip The number of bytes to skip. - * @param dataSpecToUse The {@link DataSpec}. - * @throws IOException If the thread is interrupted during the operation, or if the data ended - * before skipping the specified number of bytes. - */ - @SuppressWarnings("checkstyle:FinalParameters") - private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException { - if (bytesToSkip == 0) { - return; - } - - final byte[] skipBuffer = new byte[4096]; - while (bytesToSkip > 0) { - final int readLength = (int) min(bytesToSkip, skipBuffer.length); - final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); - if (Thread.currentThread().isInterrupted()) { - throw new HttpDataSourceException( - new InterruptedIOException(), - dataSpecToUse, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - } - - if (read == -1) { - throw new HttpDataSourceException( - dataSpecToUse, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, - HttpDataSourceException.TYPE_OPEN); - } - - bytesToSkip -= read; - bytesTransferred(read); - } - } - - /** - * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at - * index {@code offset}. - * - *

- * This method blocks until at least one byte of data can be read, the end of the opened range - * is detected, or an exception is thrown. - *

- * - * @param buffer The buffer into which the read data should be stored. - * @param offset The start offset into {@code buffer} at which data should be written. - * @param readLength The maximum number of bytes to read. - * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened - * range is reached. - * @throws IOException If an error occurs reading from the source. - */ - @SuppressWarnings("checkstyle:FinalParameters") - private int readInternal(final byte[] buffer, final int offset, int readLength) - throws IOException { - if (readLength == 0) { - return 0; - } - if (bytesToRead != C.LENGTH_UNSET) { - final long bytesRemaining = bytesToRead - bytesRead; - if (bytesRemaining == 0) { - return C.RESULT_END_OF_INPUT; - } - readLength = (int) min(readLength, bytesRemaining); - } - - final int read = castNonNull(inputStream).read(buffer, offset, readLength); - if (read == -1) { - return C.RESULT_END_OF_INPUT; - } - - bytesRead += read; - bytesTransferred(read); - return read; - } - - /** - * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can - * block for a long time if the stream has a lot of data remaining. Call this method before - * closing the input stream to make a best effort to cause the input stream to encounter an - * unexpected end of input, working around this issue. On other platform API levels, the method - * does nothing. - * - * @param connection The connection whose {@link InputStream} should be terminated. - * @param bytesRemaining The number of bytes remaining to be read from the input stream if its - * length is known. {@link C#LENGTH_UNSET} otherwise. - */ - private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection, - final long bytesRemaining) { - if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { - return; - } - - try { - final InputStream inputStream = connection.getInputStream(); - if (bytesRemaining == C.LENGTH_UNSET) { - // If the input stream has already ended, do nothing. The socket may be re-used. - if (inputStream.read() == -1) { - return; - } - } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { - // There isn't much data left. Prefer to allow it to drain, which may allow the - // socket to be re-used. - return; - } - final String className = inputStream.getClass().getName(); - if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream" - .equals(className) - || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" - .equals(className)) { - final Class superclass = inputStream.getClass().getSuperclass(); - final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod( - "unexpectedEndOfInput"); - unexpectedEndOfInput.setAccessible(true); - unexpectedEndOfInput.invoke(inputStream); - } - } catch (final Exception e) { - // If an IOException then the connection didn't ever have an input stream, or it was - // closed already. If another type of exception then something went wrong, most likely - // the device isn't using okhttp. - } - } - - /** - * Closes the current connection quietly, if there is one. - */ - private void closeConnectionQuietly() { - if (connection != null) { - try { - connection.disconnect(); - } catch (final Exception e) { - Log.e(TAG, "Unexpected error while disconnecting", e); - } - connection = null; - } - } - - private static boolean isCompressed(@NonNull final HttpURLConnection connection) { - final String contentEncoding = connection.getHeaderField("Content-Encoding"); - return "gzip".equalsIgnoreCase(contentEncoding); - } - - /** - * Builds a {@code range} parameter for the given position and length. - * - *

- * To fetch its contents, YouTube use range requests which append a {@code range} parameter - * to videoplayback URLs instead of the {@code Range} header (even if the server respond - * correctly when requesting a range of a ressouce with it). - *

- * - *

- * The parameter works in the same way as the header. - *

- * - * @param position The request position. - * @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded. - * @return The corresponding {@code range} parameter, or {@code null} if this parameter is - * unnecessary because the whole resource is being requested. - */ - @Nullable - private static String buildRangeParameter(final long position, final long length) { - if (position == 0 && length == C.LENGTH_UNSET) { - return null; - } - - final StringBuilder rangeParameter = new StringBuilder(); - rangeParameter.append("&range="); - rangeParameter.append(position); - rangeParameter.append("-"); - if (length != C.LENGTH_UNSET) { - rangeParameter.append(position + length - 1); - } - return rangeParameter.toString(); - } - - private static final class NullFilteringHeadersMap - extends ForwardingMap> { - private final Map> headers; - - NullFilteringHeadersMap(final Map> headers) { - this.headers = headers; - } - - @NonNull - @Override - protected Map> delegate() { - return headers; - } - - @Override - public boolean containsKey(@Nullable final Object key) { - return key != null && super.containsKey(key); - } - - @Nullable - @Override - public List get(@Nullable final Object key) { - return key == null ? null : super.get(key); - } - - @NonNull - @Override - public Set keySet() { - return Sets.filter(super.keySet(), Objects::nonNull); - } - - @NonNull - @Override - public Set>> entrySet() { - return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); - } - - @Override - public int size() { - return super.size() - (super.containsKey(null) ? 1 : 0); - } - - @Override - public boolean isEmpty() { - return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); - } - - @Override - public boolean containsValue(@Nullable final Object value) { - return super.standardContainsValue(value); - } - - @Override - public boolean equals(@Nullable final Object object) { - return object != null && super.standardEquals(object); - } - - @Override - public int hashCode() { - return super.standardHashCode(); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java b/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java deleted file mode 100644 index fc1f9d80d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.schabi.newpipe.player.event; - -public interface OnKeyDownListener { - boolean onKeyDown(int keyCode); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java deleted file mode 100644 index 2cca259c2..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.schabi.newpipe.player.event; - -import com.google.android.exoplayer2.PlaybackParameters; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; - -public interface PlayerEventListener { - void onQueueUpdate(PlayQueue queue); - void onPlaybackUpdate(int state, int repeatMode, boolean shuffled, - PlaybackParameters parameters); - void onProgressUpdate(int currentProgress, int duration, int bufferPercent); - void onMetadataUpdate(StreamInfo info, PlayQueue queue); - default void onAudioTrackUpdate() { } - void onServiceStopped(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java deleted file mode 100644 index 8c18fd2ad..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.player.event; - -import com.google.android.exoplayer2.PlaybackException; - -public interface PlayerServiceEventListener extends PlayerEventListener { - void onViewCreated(); - - void onFullscreenStateChanged(boolean fullscreen); - - void onScreenRotationButtonClicked(); - - void onMoreOptionsLongClicked(); - - void onPlayerError(PlaybackException error, boolean isCatchableException); - - void hideSystemUiIfNeeded(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java deleted file mode 100644 index 549abc952..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.player.event; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.Player; - -/** - * In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player - * connections and disconnections. "Connected" here means that the service (resp. the - * player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}. - * "Disconnected" means that either the service (resp. the player) was stopped completely, or that - * {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound. - */ -public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { - /** - * The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder}, - * but the player may not be active at this moment, e.g. in case the service is running to - * respond to Android Auto media browser queries without playing anything. - * {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there - * is a player. - * - * @param playerService the newly connected player service - */ - void onServiceConnected(@NonNull PlayerService playerService); - - /** - * The player service is already connected and the player was just started. - * - * @param player the newly connected or started player - * @param playAfterConnect whether to open the video player in the video details fragment - */ - void onPlayerConnected(@NonNull Player player, boolean playAfterConnect); - - /** - * The player got disconnected, for one of these reasons: the player is getting closed while - * leaving the service open for future media browser queries, the service is stopping - * completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding. - */ - void onPlayerDisconnected(); - - /** - * The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder}, - * either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because - * the service is stopping completely. - */ - void onServiceDisconnected(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt deleted file mode 100644 index 52175a3bf..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt +++ /dev/null @@ -1,196 +0,0 @@ -package org.schabi.newpipe.player.gesture - -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.View -import androidx.core.os.postDelayed -import org.schabi.newpipe.databinding.PlayerBinding -import org.schabi.newpipe.player.Player -import org.schabi.newpipe.player.ui.VideoPlayerUi - -/** - * Base gesture handling for [Player] - * - * This class contains the logic for the player gestures like View preparations - * and provides some abstract methods to make it easier separating the logic from the UI. - */ -abstract class BasePlayerGestureListener( - private val playerUi: VideoPlayerUi -) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { - - protected val player: Player = playerUi.player - protected val binding: PlayerBinding = playerUi.binding - - override fun onTouch(v: View, event: MotionEvent): Boolean { - playerUi.gestureDetector.onTouchEvent(event) - return false - } - - private fun onDoubleTap( - event: MotionEvent, - portion: DisplayPortion - ) { - if (DEBUG) { - Log.d( - TAG, - "onDoubleTap called with playerType = [" + - player.playerType + "], portion = [" + portion + "]" - ) - } - if (playerUi.isSomePopupMenuVisible) { - playerUi.hideControls(0, 0) - } - if (portion === DisplayPortion.LEFT || portion === DisplayPortion.RIGHT) { - startMultiDoubleTap(event) - } else if (portion === DisplayPortion.MIDDLE) { - player.playPause() - if (player.isPlaying) { - playerUi.hideControls(0, 0) - } - } - } - - protected fun onSingleTap() { - if (playerUi.isControlsVisible) { - playerUi.hideControls(150, 0) - return - } - // -- Controls are not visible -- - - // When player is completed show controls and don't hide them later - if (player.currentState == Player.STATE_COMPLETED) { - playerUi.showControls(0) - } else { - playerUi.showControlsThenHide() - } - } - - open fun onScrollEnd(event: MotionEvent) { - if (DEBUG) { - Log.d( - TAG, - "onScrollEnd called with playerType = [" + - player.playerType + "]" - ) - } - if (playerUi.isControlsVisible && player.currentState == Player.STATE_PLAYING) { - playerUi.hideControls( - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, - VideoPlayerUi.DEFAULT_CONTROLS_HIDE_TIME - ) - } - } - - // /////////////////////////////////////////////////////////////////// - // Simple gestures - // /////////////////////////////////////////////////////////////////// - - override fun onDown(e: MotionEvent): Boolean { - if (DEBUG) { - Log.d(TAG, "onDown called with e = [$e]") - } - - if (isDoubleTapping && isDoubleTapEnabled) { - doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e)) - return true - } - - if (onDownNotDoubleTapping(e)) { - return super.onDown(e) - } - return true - } - - /** - * @return true if `super.onDown(e)` should be called, false otherwise - */ - open fun onDownNotDoubleTapping(e: MotionEvent): Boolean { - return false // do not call super.onDown(e) by default, overridden for popup player - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - if (DEBUG) { - Log.d(TAG, "onDoubleTap called with e = [$e]") - } - - onDoubleTap(e, getDisplayPortion(e)) - return true - } - - // /////////////////////////////////////////////////////////////////// - // Multi double tapping - // /////////////////////////////////////////////////////////////////// - - private var doubleTapControls: DoubleTapListener? = null - - private val isDoubleTapEnabled: Boolean - get() = doubleTapDelay > 0 - - var isDoubleTapping = false - private set - - fun doubleTapControls(listener: DoubleTapListener) = apply { - doubleTapControls = listener - } - - private var doubleTapDelay = DOUBLE_TAP_DELAY - private val doubleTapHandler: Handler = Handler(Looper.getMainLooper()) - - private fun startMultiDoubleTap(e: MotionEvent) { - if (!isDoubleTapping) { - if (DEBUG) { - Log.d(TAG, "startMultiDoubleTap called with e = [$e]") - } - - keepInDoubleTapMode() - doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e)) - } - } - - fun keepInDoubleTapMode() { - if (DEBUG) { - Log.d(TAG, "keepInDoubleTapMode called") - } - - isDoubleTapping = true - doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) - doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) { - if (DEBUG) { - Log.d(TAG, "doubleTapRunnable called") - } - - isDoubleTapping = false - doubleTapControls?.onDoubleTapFinished() - } - } - - fun endMultiDoubleTap() { - if (DEBUG) { - Log.d(TAG, "endMultiDoubleTap called") - } - - isDoubleTapping = false - doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) - doubleTapControls?.onDoubleTapFinished() - } - - // /////////////////////////////////////////////////////////////////// - // Utils - // /////////////////////////////////////////////////////////////////// - - abstract fun getDisplayPortion(e: MotionEvent): DisplayPortion - - // Currently needed for scrolling since there is no action more the middle portion - abstract fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion - - companion object { - private const val TAG = "BasePlayerGestListener" - private val DEBUG = Player.DEBUG - - private const val DOUBLE_TAP = "doubleTap" - private const val DOUBLE_TAP_DELAY = 550L - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java deleted file mode 100644 index 0970dbeb6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.schabi.newpipe.player.gesture; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.coordinatorlayout.widget.CoordinatorLayout; - -import com.google.android.material.bottomsheet.BottomSheetBehavior; - -import org.schabi.newpipe.R; - -import java.util.List; - -public class CustomBottomSheetBehavior extends BottomSheetBehavior { - - public CustomBottomSheetBehavior(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - Rect globalRect = new Rect(); - private boolean skippingInterception = false; - private final List skipInterceptionOfElements = List.of( - R.id.detail_content_root_layout, R.id.relatedItemsLayout, - R.id.itemsListPanel, R.id.view_pager, R.id.tab_layout, R.id.bottomControls, - R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); - - @Override - public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, - @NonNull final FrameLayout child, - @NonNull final MotionEvent event) { - // Drop following when action ends - if (event.getAction() == MotionEvent.ACTION_CANCEL - || event.getAction() == MotionEvent.ACTION_UP) { - skippingInterception = false; - } - - // Found that user still swiping, continue following - if (skippingInterception || getState() == BottomSheetBehavior.STATE_SETTLING) { - return false; - } - - // The interception listens for the child view with the id "fragment_player_holder", - // so the following two-finger gesture will be triggered only for the player view on - // portrait and for the top controls (visible) on landscape. - setSkipCollapsed(event.getPointerCount() == 2); - if (event.getPointerCount() == 2) { - return super.onInterceptTouchEvent(parent, child, event); - } - - // Don't need to do anything if bottomSheet isn't expanded - if (getState() == BottomSheetBehavior.STATE_EXPANDED - && event.getAction() == MotionEvent.ACTION_DOWN) { - // Without overriding scrolling will not work when user touches these elements - for (final int element : skipInterceptionOfElements) { - final View view = child.findViewById(element); - if (view != null) { - final boolean visible = view.getGlobalVisibleRect(globalRect); - if (visible - && globalRect.contains((int) event.getRawX(), (int) event.getRawY())) { - // Makes bottom part of the player draggable in portrait when - // playbackControlRoot is hidden - if (element == R.id.bottomControls - && child.findViewById(R.id.playbackControlRoot) - .getVisibility() != View.VISIBLE) { - return super.onInterceptTouchEvent(parent, child, event); - } - skippingInterception = true; - return false; - } - } - } - } - - return super.onInterceptTouchEvent(parent, child, event); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt deleted file mode 100644 index c5d483628..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.player.gesture - -enum class DisplayPortion { - LEFT, - MIDDLE, - RIGHT, - LEFT_HALF, - RIGHT_HALF -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt deleted file mode 100644 index fc026abd9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.player.gesture - -interface DoubleTapListener { - fun onDoubleTapStarted(portion: DisplayPortion) - fun onDoubleTapProgressDown(portion: DisplayPortion) - fun onDoubleTapFinished() -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt deleted file mode 100644 index 7cc9ba224..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt +++ /dev/null @@ -1,240 +0,0 @@ -package org.schabi.newpipe.player.gesture - -import android.util.Log -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import android.widget.ProgressBar -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.isVisible -import kotlin.math.abs -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R -import org.schabi.newpipe.ktx.AnimationType -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.player.Player -import org.schabi.newpipe.player.helper.AudioReactor -import org.schabi.newpipe.player.helper.PlayerHelper -import org.schabi.newpipe.player.ui.MainPlayerUi -import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx - -/** - * GestureListener for the player - * - * While [BasePlayerGestureListener] contains the logic behind the single gestures - * this class focuses on the visual aspect like hiding and showing the controls or changing - * volume/brightness during scrolling for specific events. - */ -class MainPlayerGestureListener( - private val playerUi: MainPlayerUi -) : BasePlayerGestureListener(playerUi), OnTouchListener { - private var isMoving = false - - override fun onTouch(v: View, event: MotionEvent): Boolean { - super.onTouch(v, event) - if (event.action == MotionEvent.ACTION_UP && isMoving) { - isMoving = false - onScrollEnd(event) - } - return when (event.action) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen) - true - } - - MotionEvent.ACTION_UP -> { - v.parent?.requestDisallowInterceptTouchEvent(false) - false - } - - else -> true - } - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if (DEBUG) { - Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") - } - - if (isDoubleTapping) { - return true - } - super.onSingleTapConfirmed(e) - - if (player.currentState != Player.STATE_BLOCKED) { - onSingleTap() - } - return true - } - - private fun onScrollVolume(distanceY: Float) { - val bar: ProgressBar = binding.volumeProgressBar - val audioReactor: AudioReactor = player.audioReactor - - // If we just started sliding, change the progress bar to match the system volume - if (!binding.volumeRelativeLayout.isVisible) { - val volumePercent: Float = audioReactor.volume / audioReactor.maxVolume.toFloat() - bar.progress = (volumePercent * bar.max).toInt() - } - - // Update progress bar - binding.volumeProgressBar.incrementProgressBy(distanceY.toInt()) - - // Update volume - val currentProgressPercent: Float = bar.progress / bar.max.toFloat() - val currentVolume = (audioReactor.maxVolume * currentProgressPercent).toInt() - audioReactor.volume = currentVolume - if (DEBUG) { - Log.d(TAG, "onScroll().volumeControl, currentVolume = $currentVolume") - } - - // Update player center image - binding.volumeImageView.setImageDrawable( - AppCompatResources.getDrawable( - player.context, - when { - currentProgressPercent <= 0 -> R.drawable.ic_volume_off - currentProgressPercent < 0.25 -> R.drawable.ic_volume_mute - currentProgressPercent < 0.75 -> R.drawable.ic_volume_down - else -> R.drawable.ic_volume_up - } - ) - ) - - // Make sure the correct layout is visible - if (!binding.volumeRelativeLayout.isVisible) { - binding.volumeRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) - } - binding.brightnessRelativeLayout.isVisible = false - } - - private fun onScrollBrightness(distanceY: Float) { - val parent: AppCompatActivity = playerUi.parentActivity.orElse(null) ?: return - val window = parent.window - val layoutParams = window.attributes - val bar: ProgressBar = binding.brightnessProgressBar - - // Update progress bar - val oldBrightness = layoutParams.screenBrightness - bar.progress = (bar.max * oldBrightness.coerceIn(0f, 1f)).toInt() - bar.incrementProgressBy(distanceY.toInt()) - - // Update brightness - val currentProgressPercent = bar.progress.toFloat() / bar.max - layoutParams.screenBrightness = currentProgressPercent - window.attributes = layoutParams - - // Save current brightness level - PlayerHelper.setScreenBrightness(parent, currentProgressPercent) - if (DEBUG) { - Log.d( - TAG, - "onScroll().brightnessControl, " + - "currentBrightness = " + currentProgressPercent - ) - } - - // Update player center image - binding.brightnessImageView.setImageDrawable( - AppCompatResources.getDrawable( - player.context, - when { - currentProgressPercent < 0.25 -> R.drawable.ic_brightness_low - currentProgressPercent < 0.75 -> R.drawable.ic_brightness_medium - else -> R.drawable.ic_brightness_high - } - ) - ) - - // Make sure the correct layout is visible - if (!binding.brightnessRelativeLayout.isVisible) { - binding.brightnessRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) - } - binding.volumeRelativeLayout.isVisible = false - } - - override fun onScrollEnd(event: MotionEvent) { - super.onScrollEnd(event) - if (binding.volumeRelativeLayout.isVisible) { - binding.volumeRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) - } - if (binding.brightnessRelativeLayout.isVisible) { - binding.brightnessRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) - } - } - - override fun onScroll( - initialEvent: MotionEvent?, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - if (initialEvent == null || !playerUi.isFullscreen) { - return false - } - - // Calculate heights of status and navigation bars - val statusBarHeight = getAndroidDimenPx(player.context, "status_bar_height") - val navigationBarHeight = getAndroidDimenPx(player.context, "navigation_bar_height") - - // Do not handle this event if initially it started from status or navigation bars - val isTouchingStatusBar = initialEvent.y < statusBarHeight - val isTouchingNavigationBar = initialEvent.y > (binding.root.height - navigationBarHeight) - if (isTouchingStatusBar || isTouchingNavigationBar) { - return false - } - - val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD - if ( - !isMoving && (insideThreshold || abs(distanceX) > abs(distanceY)) || - player.currentState == Player.STATE_COMPLETED - ) { - return false - } - - isMoving = true - - // -- Brightness and Volume control -- - if (getDisplayHalfPortion(initialEvent) == DisplayPortion.RIGHT_HALF) { - when (PlayerHelper.getActionForRightGestureSide(player.context)) { - player.context.getString(R.string.volume_control_key) -> - onScrollVolume(distanceY) - - player.context.getString(R.string.brightness_control_key) -> - onScrollBrightness(distanceY) - } - } else { - when (PlayerHelper.getActionForLeftGestureSide(player.context)) { - player.context.getString(R.string.volume_control_key) -> - onScrollVolume(distanceY) - - player.context.getString(R.string.brightness_control_key) -> - onScrollBrightness(distanceY) - } - } - - return true - } - - override fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return when { - e.x < binding.root.width / 3.0 -> DisplayPortion.LEFT - e.x > binding.root.width * 2.0 / 3.0 -> DisplayPortion.RIGHT - else -> DisplayPortion.MIDDLE - } - } - - override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { - return when { - e.x < binding.root.width / 2.0 -> DisplayPortion.LEFT_HALF - else -> DisplayPortion.RIGHT_HALF - } - } - - companion object { - private val TAG = MainPlayerGestureListener::class.java.simpleName - private val DEBUG = MainActivity.DEBUG - private const val MOVEMENT_THRESHOLD = 40 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt deleted file mode 100644 index 60752652e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt +++ /dev/null @@ -1,290 +0,0 @@ -package org.schabi.newpipe.player.gesture - -import android.util.Log -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import androidx.core.view.isVisible -import kotlin.math.abs -import kotlin.math.hypot -import kotlin.math.max -import kotlin.math.min -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.ktx.AnimationType -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.player.ui.PopupPlayerUi - -class PopupPlayerGestureListener( - private val playerUi: PopupPlayerUi -) : BasePlayerGestureListener(playerUi) { - - private var isMoving = false - - private var initialPopupX: Int = -1 - private var initialPopupY: Int = -1 - private var isResizing = false - - // initial coordinates and distance between fingers - private var initPointerDistance = -1.0 - private var initFirstPointerX = -1f - private var initFirstPointerY = -1f - private var initSecPointerX = -1f - private var initSecPointerY = -1f - - override fun onTouch(v: View, event: MotionEvent): Boolean { - super.onTouch(v, event) - if (event.pointerCount == 2 && !isMoving && !isResizing) { - if (DEBUG) { - Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") - } - onPopupResizingStart() - - // record coordinates of fingers - initFirstPointerX = event.getX(0) - initFirstPointerY = event.getY(0) - initSecPointerX = event.getX(1) - initSecPointerY = event.getY(1) - // record distance between fingers - initPointerDistance = hypot( - initFirstPointerX - initSecPointerX.toDouble(), - initFirstPointerY - initSecPointerY.toDouble() - ) - - isResizing = true - } - if (event.action == MotionEvent.ACTION_MOVE && !isMoving && isResizing) { - if (DEBUG) { - Log.d( - TAG, - "onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" + - "[${event.rawX}, ${event.rawY}]" - ) - } - return handleMultiDrag(event) - } - if (event.action == MotionEvent.ACTION_UP) { - if (DEBUG) { - Log.d( - TAG, - "onTouch() ACTION_UP > v = [$v], e1.getRaw =" + - " [${event.rawX}, ${event.rawY}]" - ) - } - if (isMoving) { - isMoving = false - onScrollEnd(event) - } - if (isResizing) { - isResizing = false - - initPointerDistance = (-1).toDouble() - initFirstPointerX = (-1).toFloat() - initFirstPointerY = (-1).toFloat() - initSecPointerX = (-1).toFloat() - initSecPointerY = (-1).toFloat() - - onPopupResizingEnd() - player.changeState(player.currentState) - } - if (!playerUi.isPopupClosing) { - playerUi.savePopupPositionAndSizeToPrefs() - } - } - - v.performClick() - return true - } - - override fun onScrollEnd(event: MotionEvent) { - super.onScrollEnd(event) - if (playerUi.isInsideClosingRadius(event)) { - playerUi.closePopup() - } else if (!playerUi.isPopupClosing) { - playerUi.closeOverlayBinding.closeButton.animate(false, 200) - binding.closingOverlay.animate(false, 200) - } - } - - private fun handleMultiDrag(event: MotionEvent): Boolean { - if (initPointerDistance == -1.0 || event.pointerCount != 2) { - return false - } - - // get the movements of the fingers - val firstPointerMove = hypot( - event.getX(0) - initFirstPointerX.toDouble(), - event.getY(0) - initFirstPointerY.toDouble() - ) - val secPointerMove = hypot( - event.getX(1) - initSecPointerX.toDouble(), - event.getY(1) - initSecPointerY.toDouble() - ) - - // minimum threshold beyond which pinch gesture will work - val minimumMove = ViewConfiguration.get(player.context).scaledTouchSlop - if (max(firstPointerMove, secPointerMove) <= minimumMove) { - return false - } - - // calculate current distance between the pointers - val currentPointerDistance = hypot( - event.getX(0) - event.getX(1).toDouble(), - event.getY(0) - event.getY(1).toDouble() - ) - - val popupWidth = playerUi.popupLayoutParams.width.toDouble() - // change co-ordinates of popup so the center stays at the same position - val newWidth = popupWidth * currentPointerDistance / initPointerDistance - initPointerDistance = currentPointerDistance - playerUi.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt() - - playerUi.checkPopupPositionBounds() - playerUi.updateScreenSize() - playerUi.changePopupSize(min(playerUi.screenWidth.toDouble(), newWidth).toInt()) - return true - } - - private fun onPopupResizingStart() { - if (DEBUG) { - Log.d(TAG, "onPopupResizingStart called") - } - binding.loadingPanel.visibility = View.GONE - playerUi.hideControls(0, 0) - binding.fastSeekOverlay.animate(false, 0) - binding.currentDisplaySeek.animate(false, 0, AnimationType.ALPHA, 0) - } - - private fun onPopupResizingEnd() { - if (DEBUG) { - Log.d(TAG, "onPopupResizingEnd called") - } - } - - override fun onLongPress(e: MotionEvent) { - playerUi.updateScreenSize() - playerUi.checkPopupPositionBounds() - playerUi.changePopupSize(playerUi.screenWidth) - } - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - return if (player.popupPlayerSelected()) { - val absVelocityX = abs(velocityX) - val absVelocityY = abs(velocityY) - if (absVelocityX.coerceAtLeast(absVelocityY) > TOSS_FLING_VELOCITY) { - if (absVelocityX > TOSS_FLING_VELOCITY) { - playerUi.popupLayoutParams.x = velocityX.toInt() - } - if (absVelocityY > TOSS_FLING_VELOCITY) { - playerUi.popupLayoutParams.y = velocityY.toInt() - } - playerUi.checkPopupPositionBounds() - playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) - return true - } - return false - } else { - true - } - } - - override fun onDownNotDoubleTapping(e: MotionEvent): Boolean { - // Fix popup position when the user touch it, it may have the wrong one - // because the soft input is visible (the draggable area is currently resized). - playerUi.updateScreenSize() - playerUi.checkPopupPositionBounds() - playerUi.popupLayoutParams.let { - initialPopupX = it.x - initialPopupY = it.y - } - return true // we want `super.onDown(e)` to be called - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if (DEBUG) { - Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") - } - - if (isDoubleTapping) { - return true - } - if (player.exoPlayerIsNull()) { - return false - } - - onSingleTap() - return true - } - - override fun onScroll( - initialEvent: MotionEvent?, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - if (initialEvent == null) { - return false - } - - if (isResizing) { - return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) - } - - if (!isMoving) { - playerUi.closeOverlayBinding.closeButton.animate(true, 200) - } - - isMoving = true - - val diffX = (movingEvent.rawX - initialEvent.rawX) - val posX = (initialPopupX + diffX).coerceIn( - 0f, - (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat() - .coerceAtLeast(0f) - ) - val diffY = (movingEvent.rawY - initialEvent.rawY) - val posY = (initialPopupY + diffY).coerceIn( - 0f, - (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat() - .coerceAtLeast(0f) - ) - - playerUi.popupLayoutParams.x = posX.toInt() - playerUi.popupLayoutParams.y = posY.toInt() - - // -- Determine if the ClosingOverlayView (red X) has to be shown or hidden -- - val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent) - // Check if an view is in expected state and if not animate it into the correct state - if (binding.closingOverlay.isVisible != showClosingOverlayView) { - binding.closingOverlay.animate(showClosingOverlayView, 200) - } - - playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) - return true - } - - override fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return when { - e.x < playerUi.popupLayoutParams.width / 3.0 -> DisplayPortion.LEFT - e.x > playerUi.popupLayoutParams.width * 2.0 / 3.0 -> DisplayPortion.RIGHT - else -> DisplayPortion.MIDDLE - } - } - - override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { - return when { - e.x < playerUi.popupLayoutParams.width / 2.0 -> DisplayPortion.LEFT_HALF - else -> DisplayPortion.RIGHT_HALF - } - } - - companion object { - private val TAG = PopupPlayerGestureListener::class.java.simpleName - private val DEBUG = MainActivity.DEBUG - private const val TOSS_FLING_VELOCITY = 2500 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java deleted file mode 100644 index 084336d54..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ /dev/null @@ -1,164 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.content.Context; -import android.content.Intent; -import android.media.AudioManager; -import android.media.audiofx.AudioEffect; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.media.AudioFocusRequestCompat; -import androidx.media.AudioManagerCompat; - -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.analytics.AnalyticsListener; - -public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { - - private static final String TAG = "AudioFocusReactor"; - - private static final int DUCK_DURATION = 1500; - private static final float DUCK_AUDIO_TO = .2f; - - private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN; - private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; - - private final ExoPlayer player; - private final Context context; - private final AudioManager audioManager; - - private final AudioFocusRequestCompat request; - - public AudioReactor(@NonNull final Context context, - @NonNull final ExoPlayer player) { - this.player = player; - this.context = context; - this.audioManager = ContextCompat.getSystemService(context, AudioManager.class); - player.addAnalyticsListener(this); - - request = new AudioFocusRequestCompat.Builder(FOCUS_GAIN_TYPE) - //.setAcceptsDelayedFocusGain(true) - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(this) - .build(); - } - - public void dispose() { - abandonAudioFocus(); - player.removeAnalyticsListener(this); - notifyAudioSessionUpdate(false, player.getAudioSessionId()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Audio Manager - //////////////////////////////////////////////////////////////////////////*/ - - public void requestAudioFocus() { - AudioManagerCompat.requestAudioFocus(audioManager, request); - } - - public void abandonAudioFocus() { - AudioManagerCompat.abandonAudioFocusRequest(audioManager, request); - } - - public int getVolume() { - return audioManager.getStreamVolume(STREAM_TYPE); - } - - public void setVolume(final int volume) { - audioManager.setStreamVolume(STREAM_TYPE, volume, 0); - } - - public int getMaxVolume() { - return AudioManagerCompat.getStreamMaxVolume(audioManager, STREAM_TYPE); - } - - /*////////////////////////////////////////////////////////////////////////// - // AudioFocus - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAudioFocusChange(final int focusChange) { - Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]"); - switch (focusChange) { - case AudioManager.AUDIOFOCUS_GAIN: - onAudioFocusGain(); - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - onAudioFocusLossCanDuck(); - break; - case AudioManager.AUDIOFOCUS_LOSS: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - onAudioFocusLoss(); - break; - } - } - - private void onAudioFocusGain() { - Log.d(TAG, "onAudioFocusGain() called"); - player.setVolume(DUCK_AUDIO_TO); - animateAudio(DUCK_AUDIO_TO, 1.0f); - - if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { - player.play(); - } - } - - private void onAudioFocusLoss() { - Log.d(TAG, "onAudioFocusLoss() called"); - player.pause(); - } - - private void onAudioFocusLossCanDuck() { - Log.d(TAG, "onAudioFocusLossCanDuck() called"); - // Set the volume to 1/10 on ducking - player.setVolume(DUCK_AUDIO_TO); - } - - private void animateAudio(final float from, final float to) { - final ValueAnimator valueAnimator = new ValueAnimator(); - valueAnimator.setFloatValues(from, to); - valueAnimator.setDuration(AudioReactor.DUCK_DURATION); - valueAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(final Animator animation) { - player.setVolume(from); - } - - @Override - public void onAnimationCancel(final Animator animation) { - player.setVolume(to); - } - - @Override - public void onAnimationEnd(final Animator animation) { - player.setVolume(to); - } - }); - valueAnimator.addUpdateListener(animation -> - player.setVolume(((float) animation.getAnimatedValue()))); - valueAnimator.start(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Audio Processing - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAudioSessionIdChanged(@NonNull final EventTime eventTime, - final int audioSessionId) { - notifyAudioSessionUpdate(true, audioSessionId); - } - private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) { - final Intent intent = new Intent(active - ? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION - : AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); - intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId); - intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); - context.sendBroadcast(intent); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java deleted file mode 100644 index 41fcc823a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; - -final class CacheFactory implements DataSource.Factory { - private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - - private final Context context; - private final TransferListener transferListener; - private final DataSource.Factory upstreamDataSourceFactory; - private final SimpleCache cache; - - CacheFactory(final Context context, - final TransferListener transferListener, - final SimpleCache cache, - final DataSource.Factory upstreamDataSourceFactory) { - this.context = context; - this.transferListener = transferListener; - this.cache = cache; - this.upstreamDataSourceFactory = upstreamDataSourceFactory; - } - - @NonNull - @Override - public DataSource createDataSource() { - final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, - upstreamDataSourceFactory) - .setTransferListener(transferListener) - .createDataSource(); - - final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = - new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); - return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java deleted file mode 100644 index 66ac6d50b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.Context; -import android.os.Handler; - -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; -import com.google.android.exoplayer2.video.VideoRendererEventListener; - -/** - * A {@link MediaCodecVideoRenderer} which always enable the output surface workaround that - * ExoPlayer enables on several devices which are known to implement - * {@link android.media.MediaCodec#setOutputSurface(android.view.Surface) - * MediaCodec.setOutputSurface(Surface)} incorrectly. - * - *

- * See {@link MediaCodecVideoRenderer#codecNeedsSetOutputSurfaceWorkaround(String)} for more - * details. - *

- * - *

- * This custom {@link MediaCodecVideoRenderer} may be useful in the case a device is affected by - * this issue but is not present in ExoPlayer's list. - *

- * - *

- * This class has only effect on devices with Android 6 and higher, as the {@code setOutputSurface} - * method is only implemented in these Android versions and the method used as a workaround is - * always applied on older Android versions (releasing and re-instantiating video codec instances). - *

- */ -public final class CustomMediaCodecVideoRenderer extends MediaCodecVideoRenderer { - - @SuppressWarnings({"checkstyle:ParameterNumber", "squid:S107"}) - public CustomMediaCodecVideoRenderer(final Context context, - final MediaCodecAdapter.Factory codecAdapterFactory, - final MediaCodecSelector mediaCodecSelector, - final long allowedJoiningTimeMs, - final boolean enableDecoderFallback, - @Nullable final Handler eventHandler, - @Nullable final VideoRendererEventListener eventListener, - final int maxDroppedFramesToNotify) { - super(context, codecAdapterFactory, mediaCodecSelector, allowedJoiningTimeMs, - enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); - } - - @Override - protected boolean codecNeedsSetOutputSurfaceWorkaround(final String name) { - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java deleted file mode 100644 index 668b48c30..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.Context; -import android.os.Handler; - -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.Renderer; -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.video.VideoRendererEventListener; - -import java.util.ArrayList; - -/** - * A {@link DefaultRenderersFactory} which only uses {@link CustomMediaCodecVideoRenderer} as an - * implementation of video codec renders. - * - *

- * As no ExoPlayer extension is currently used, the reflection code used by ExoPlayer to try to - * load video extension libraries is not needed in our case and has been removed. This should be - * changed in the case an extension is shipped with the app, such as the AV1 one. - *

- */ -public final class CustomRenderersFactory extends DefaultRenderersFactory { - - public CustomRenderersFactory(final Context context) { - super(context); - } - - @SuppressWarnings("checkstyle:ParameterNumber") - @Override - protected void buildVideoRenderers(final Context context, - @ExtensionRendererMode final int extensionRendererMode, - final MediaCodecSelector mediaCodecSelector, - final boolean enableDecoderFallback, - final Handler eventHandler, - final VideoRendererEventListener eventListener, - final long allowedVideoJoiningTimeMs, - final ArrayList out) { - out.add(new CustomMediaCodecVideoRenderer(context, getCodecAdapterFactory(), - mediaCodecSelector, allowedVideoJoiningTimeMs, enableDecoderFallback, eventHandler, - eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java deleted file mode 100644 index ec0e4e4a7..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import com.google.android.exoplayer2.DefaultLoadControl; - -public class LoadController extends DefaultLoadControl { - - public static final String TAG = "LoadController"; - private boolean preloadingEnabled = true; - - @Override - public void onPrepared() { - preloadingEnabled = true; - super.onPrepared(); - } - - @Override - public void onStopped() { - preloadingEnabled = true; - super.onStopped(); - } - - @Override - public void onReleased() { - preloadingEnabled = true; - super.onReleased(); - } - - @Override - public boolean shouldContinueLoading(final long playbackPositionUs, - final long bufferedDurationUs, - final float playbackSpeed) { - if (!preloadingEnabled) { - return false; - } - return super.shouldContinueLoading( - playbackPositionUs, bufferedDurationUs, playbackSpeed); - } - - public void disablePreloadingOfCurrentTrack() { - preloadingEnabled = false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java deleted file mode 100644 index 270156fe9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.Context; -import android.net.wifi.WifiManager; -import android.os.PowerManager; -import android.util.Log; - -import androidx.core.content.ContextCompat; - -public class LockManager { - private final String TAG = "LockManager@" + hashCode(); - - private final PowerManager powerManager; - private final WifiManager wifiManager; - - private PowerManager.WakeLock wakeLock; - private WifiManager.WifiLock wifiLock; - - public LockManager(final Context context) { - powerManager = ContextCompat.getSystemService(context.getApplicationContext(), - PowerManager.class); - wifiManager = ContextCompat.getSystemService(context, WifiManager.class); - } - - public void acquireWifiAndCpu() { - Log.d(TAG, "acquireWifiAndCpu() called"); - if (wakeLock != null && wakeLock.isHeld() && wifiLock != null && wifiLock.isHeld()) { - return; - } - - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); - wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); - - if (wakeLock != null) { - wakeLock.acquire(); - } - if (wifiLock != null) { - wifiLock.acquire(); - } - } - - public void releaseWifiAndCpu() { - Log.d(TAG, "releaseWifiAndCpu() called"); - if (wakeLock != null && wakeLock.isHeld()) { - wakeLock.release(); - } - if (wifiLock != null && wifiLock.isHeld()) { - wifiLock.release(); - } - - wakeLock = null; - wifiLock = null; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java deleted file mode 100644 index c5d6ada4b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ /dev/null @@ -1,595 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.Player.DEBUG; -import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable; - -import android.app.Dialog; -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.LayerDrawable; -import android.os.Bundle; -import android.util.Log; -import android.view.View; -import android.widget.CheckBox; -import android.widget.SeekBar; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.core.math.MathUtils; -import androidx.fragment.app.DialogFragment; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.livefront.bridge.Bridge; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding; -import org.schabi.newpipe.player.ui.VideoPlayerUi; -import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; -import org.schabi.newpipe.util.SliderStrategy; - -import java.util.Map; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.function.DoubleConsumer; -import java.util.function.DoubleFunction; -import java.util.function.DoubleSupplier; - -public class PlaybackParameterDialog extends DialogFragment { - private static final String TAG = "PlaybackParameterDialog"; - - // Minimum allowable range in ExoPlayer - private static final double MIN_PITCH_OR_SPEED = 0.10f; - private static final double MAX_PITCH_OR_SPEED = 3.00f; - - private static final boolean PITCH_CTRL_MODE_PERCENT = false; - private static final boolean PITCH_CTRL_MODE_SEMITONE = true; - - private static final double STEP_1_PERCENT_VALUE = 0.01f; - private static final double STEP_5_PERCENT_VALUE = 0.05f; - private static final double STEP_10_PERCENT_VALUE = 0.10f; - private static final double STEP_25_PERCENT_VALUE = 0.25f; - private static final double STEP_100_PERCENT_VALUE = 1.00f; - - private static final double DEFAULT_TEMPO = 1.00f; - private static final double DEFAULT_PITCH_PERCENT = 1.00f; - private static final double DEFAULT_STEP = STEP_25_PERCENT_VALUE; - private static final boolean DEFAULT_SKIP_SILENCE = false; - - private static final SliderStrategy QUADRATIC_STRATEGY = new SliderStrategy.Quadratic( - MIN_PITCH_OR_SPEED, - MAX_PITCH_OR_SPEED, - 1.00f, - 10_000); - - private static final SliderStrategy SEMITONE_STRATEGY = new SliderStrategy() { - @Override - public int progressOf(final double value) { - return PlayerSemitoneHelper.percentToSemitones(value) + 12; - } - - @Override - public double valueOf(final int progress) { - return PlayerSemitoneHelper.semitonesToPercent(progress - 12); - } - }; - - @Nullable - private Callback callback; - - @State - double initialTempo = DEFAULT_TEMPO; - @State - double initialPitchPercent = DEFAULT_PITCH_PERCENT; - @State - boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; - - @State - double tempo = DEFAULT_TEMPO; - @State - double pitchPercent = DEFAULT_PITCH_PERCENT; - @State - boolean skipSilence = DEFAULT_SKIP_SILENCE; - - private DialogPlaybackParameterBinding binding; - - public static PlaybackParameterDialog newInstance( - final double playbackTempo, - final double playbackPitch, - final boolean playbackSkipSilence, - final Callback callback - ) { - final PlaybackParameterDialog dialog = new PlaybackParameterDialog(); - dialog.callback = callback; - - dialog.initialTempo = playbackTempo; - dialog.initialPitchPercent = playbackPitch; - dialog.initialSkipSilence = playbackSkipSilence; - - dialog.tempo = dialog.initialTempo; - dialog.pitchPercent = dialog.initialPitchPercent; - dialog.skipSilence = dialog.initialSkipSilence; - - return dialog; - } - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - if (context instanceof Callback) { - callback = (Callback) context; - } else if (callback == null) { - dismiss(); - } - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } - - /*////////////////////////////////////////////////////////////////////////// - // Dialog - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - Bridge.restoreInstanceState(this, savedInstanceState); - - binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()); - initUI(); - - final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) - .setView(binding.getRoot()) - .setCancelable(true) - .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { - setAndUpdateTempo(initialTempo); - setAndUpdatePitch(initialPitchPercent); - setAndUpdateSkipSilence(initialSkipSilence); - updateCallback(); - }) - .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> { - setAndUpdateTempo(DEFAULT_TEMPO); - setAndUpdatePitch(DEFAULT_PITCH_PERCENT); - setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE); - updateCallback(); - }) - .setPositiveButton(R.string.ok, (dialogInterface, i) -> updateCallback()); - - return dialogBuilder.create(); - } - - /*////////////////////////////////////////////////////////////////////////// - // UI Initialization and Control - //////////////////////////////////////////////////////////////////////////*/ - - private void initUI() { - // Tempo - setText(binding.tempoMinimumText, PlayerHelper::formatSpeed, MIN_PITCH_OR_SPEED); - setText(binding.tempoMaximumText, PlayerHelper::formatSpeed, MAX_PITCH_OR_SPEED); - - binding.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); - setAndUpdateTempo(tempo); - binding.tempoSeekbar.setOnSeekBarChangeListener( - getTempoOrPitchSeekbarChangeListener( - QUADRATIC_STRATEGY, - this::onTempoSliderUpdated)); - - registerOnStepClickListener( - binding.tempoStepDown, - () -> tempo, - -1, - this::onTempoSliderUpdated); - registerOnStepClickListener( - binding.tempoStepUp, - () -> tempo, - 1, - this::onTempoSliderUpdated); - - // Pitch - binding.pitchToogleControlModes.setOnClickListener(v -> { - final boolean isCurrentlyVisible = - binding.pitchControlModeTabs.getVisibility() == View.GONE; - binding.pitchControlModeTabs.setVisibility(isCurrentlyVisible - ? View.VISIBLE - : View.GONE); - animateRotation(binding.pitchToogleControlModes, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, - isCurrentlyVisible ? 180 : 0); - }); - - getPitchControlModeComponentMappings() - .forEach(this::setupPitchControlModeTextView); - // Initialization is done at the end - - // Pitch - Percent - setText(binding.pitchPercentMinimumText, PlayerHelper::formatPitch, MIN_PITCH_OR_SPEED); - setText(binding.pitchPercentMaximumText, PlayerHelper::formatPitch, MAX_PITCH_OR_SPEED); - - binding.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); - setAndUpdatePitch(pitchPercent); - binding.pitchPercentSeekbar.setOnSeekBarChangeListener( - getTempoOrPitchSeekbarChangeListener( - QUADRATIC_STRATEGY, - this::onPitchPercentSliderUpdated)); - - registerOnStepClickListener( - binding.pitchPercentStepDown, - () -> pitchPercent, - -1, - this::onPitchPercentSliderUpdated); - registerOnStepClickListener( - binding.pitchPercentStepUp, - () -> pitchPercent, - 1, - this::onPitchPercentSliderUpdated); - - // Pitch - Semitone - binding.pitchSemitoneSeekbar.setOnSeekBarChangeListener( - getTempoOrPitchSeekbarChangeListener( - SEMITONE_STRATEGY, - this::onPitchPercentSliderUpdated)); - - registerOnSemitoneStepClickListener( - binding.pitchSemitoneStepDown, - -1, - this::onPitchPercentSliderUpdated); - registerOnSemitoneStepClickListener( - binding.pitchSemitoneStepUp, - 1, - this::onPitchPercentSliderUpdated); - - // Steps - getStepSizeComponentMappings() - .forEach(this::setupStepTextView); - // Initialize UI - setStepSizeToUI(getCurrentStepSize()); - - // Bottom controls - bindCheckboxWithBoolPref( - binding.unhookCheckbox, - R.string.playback_unhook_key, - true, - isChecked -> { - if (!isChecked) { - // when unchecked, slide back to the minimum of current tempo or pitch - ensureHookIsValidAndUpdateCallBack(); - } - }); - - setAndUpdateSkipSilence(skipSilence); - binding.skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - skipSilence = isChecked; - updateCallback(); - }); - - // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox - changePitchControlMode(isCurrentPitchControlModeSemitone()); - } - - // -- General formatting -- - - private void setText( - final TextView textView, - final DoubleFunction formatter, - final double value - ) { - Objects.requireNonNull(textView).setText(formatter.apply(value)); - } - - // -- Steps -- - - private void registerOnStepClickListener( - final TextView stepTextView, - final DoubleSupplier currentValueSupplier, - final double direction, // -1 for step down, +1 for step up - final DoubleConsumer newValueConsumer - ) { - stepTextView.setOnClickListener(view -> { - newValueConsumer.accept( - currentValueSupplier.getAsDouble() + 1 * getCurrentStepSize() * direction); - updateCallback(); - }); - } - - private void registerOnSemitoneStepClickListener( - final TextView stepTextView, - final int direction, // -1 for step down, +1 for step up - final DoubleConsumer newValueConsumer - ) { - stepTextView.setOnClickListener(view -> { - newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent( - PlayerSemitoneHelper.percentToSemitones(this.pitchPercent) + direction)); - updateCallback(); - }); - } - - // -- Pitch -- - - private void setupPitchControlModeTextView( - final boolean semitones, - final TextView textView - ) { - textView.setOnClickListener(view -> { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones) - .apply(); - - changePitchControlMode(semitones); - }); - } - - private Map getPitchControlModeComponentMappings() { - return Map.of(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent, - PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone); - } - - private void changePitchControlMode(final boolean semitones) { - // Bring all textviews into a normal state - final Map pitchCtrlModeComponentMapping = - getPitchControlModeComponentMappings(); - pitchCtrlModeComponentMapping.forEach((v, textView) -> textView.setBackground( - resolveDrawable(requireContext(), android.R.attr.selectableItemBackground))); - - // Mark the selected textview - final TextView textView = pitchCtrlModeComponentMapping.get(semitones); - if (textView != null) { - textView.setBackground(new LayerDrawable(new Drawable[]{ - resolveDrawable(requireContext(), R.attr.dashed_border), - resolveDrawable(requireContext(), android.R.attr.selectableItemBackground) - })); - } - - // Show or hide component - binding.pitchPercentControl.setVisibility(semitones ? View.GONE : View.VISIBLE); - binding.pitchSemitoneControl.setVisibility(semitones ? View.VISIBLE : View.GONE); - - if (semitones) { - // Recalculate pitch percent when changing to semitone - // (as it could be an invalid semitone value) - final double newPitchPercent = calcValidPitch(pitchPercent); - - // If the values differ set the new pitch - if (this.pitchPercent != newPitchPercent) { - if (DEBUG) { - Log.d(TAG, "Bringing pitchPercent to correct corresponding semitone: " - + "currentPitchPercent = " + pitchPercent + ", " - + "newPitchPercent = " + newPitchPercent - ); - } - this.onPitchPercentSliderUpdated(newPitchPercent); - updateCallback(); - } - } else if (!binding.unhookCheckbox.isChecked()) { - // When changing to percent it's possible that tempo is != pitch - ensureHookIsValidAndUpdateCallBack(); - } - } - - private boolean isCurrentPitchControlModeSemitone() { - return PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean( - getString(R.string.playback_adjust_by_semitones_key), - PITCH_CTRL_MODE_PERCENT); - } - - // -- Steps (Set) -- - - private void setupStepTextView( - final double stepSizeValue, - final TextView textView - ) { - setText(textView, PlaybackParameterDialog::getPercentString, stepSizeValue); - textView.setOnClickListener(view -> { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putFloat(getString(R.string.adjustment_step_key), (float) stepSizeValue) - .apply(); - - setStepSizeToUI(stepSizeValue); - }); - } - - private Map getStepSizeComponentMappings() { - return Map.of(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent, - STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent, - STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent, - STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent, - STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent); - } - - private void setStepSizeToUI(final double newStepSize) { - // Bring all textviews into a normal state - final Map stepSiteComponentMapping = getStepSizeComponentMappings(); - stepSiteComponentMapping.forEach((v, textView) -> textView.setBackground( - resolveDrawable(requireContext(), android.R.attr.selectableItemBackground))); - - // Mark the selected textview - final TextView textView = stepSiteComponentMapping.get(newStepSize); - if (textView != null) { - textView.setBackground(new LayerDrawable(new Drawable[]{ - resolveDrawable(requireContext(), R.attr.dashed_border), - resolveDrawable(requireContext(), android.R.attr.selectableItemBackground) - })); - } - - // Bind to the corresponding control components - binding.tempoStepUp.setText(getStepUpPercentString(newStepSize)); - binding.tempoStepDown.setText(getStepDownPercentString(newStepSize)); - - binding.pitchPercentStepUp.setText(getStepUpPercentString(newStepSize)); - binding.pitchPercentStepDown.setText(getStepDownPercentString(newStepSize)); - } - - private double getCurrentStepSize() { - return PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP); - } - - // -- Additional options -- - - private void setAndUpdateSkipSilence(final boolean newSkipSilence) { - this.skipSilence = newSkipSilence; - binding.skipSilenceCheckbox.setChecked(newSkipSilence); - } - - @SuppressWarnings("SameParameterValue") // this method was written to be reusable - private void bindCheckboxWithBoolPref( - @NonNull final CheckBox checkBox, - @StringRes final int resId, - final boolean defaultValue, - @NonNull final Consumer onInitialValueOrValueChange - ) { - final boolean prefValue = PreferenceManager - .getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(resId), defaultValue); - - checkBox.setChecked(prefValue); - - onInitialValueOrValueChange.accept(prefValue); - - checkBox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - // save whether pitch and tempo are unhooked or not - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putBoolean(getString(resId), isChecked) - .apply(); - - onInitialValueOrValueChange.accept(isChecked); - }); - } - - /** - * Ensures that the slider hook is valid and if not sets and updates the sliders accordingly. - *
- * You have to ensure by yourself that the hooking is active. - */ - private void ensureHookIsValidAndUpdateCallBack() { - if (tempo != pitchPercent) { - setSliders(Math.min(tempo, pitchPercent)); - updateCallback(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Sliders - //////////////////////////////////////////////////////////////////////////*/ - - private SeekBar.OnSeekBarChangeListener getTempoOrPitchSeekbarChangeListener( - final SliderStrategy sliderStrategy, - final DoubleConsumer newValueConsumer - ) { - return new SimpleOnSeekBarChangeListener() { - @Override - public void onProgressChanged(@NonNull final SeekBar seekBar, - final int progress, - final boolean fromUser) { - if (fromUser) { // ensure that the user triggered the change - newValueConsumer.accept(sliderStrategy.valueOf(progress)); - updateCallback(); - } - } - }; - } - - private void onTempoSliderUpdated(final double newTempo) { - if (!binding.unhookCheckbox.isChecked()) { - setSliders(newTempo); - } else { - setAndUpdateTempo(newTempo); - } - } - - private void onPitchPercentSliderUpdated(final double newPitch) { - if (!binding.unhookCheckbox.isChecked()) { - setSliders(newPitch); - } else { - setAndUpdatePitch(newPitch); - } - } - - private void setSliders(final double newValue) { - setAndUpdateTempo(newValue); - setAndUpdatePitch(newValue); - } - - private void setAndUpdateTempo(final double newTempo) { - this.tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); - - binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo)); - setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo); - } - - private void setAndUpdatePitch(final double newPitch) { - this.pitchPercent = calcValidPitch(newPitch); - - binding.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent)); - binding.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent)); - setText(binding.pitchPercentCurrentText, - PlayerHelper::formatPitch, - pitchPercent); - setText(binding.pitchSemitoneCurrentText, - PlayerSemitoneHelper::formatPitchSemitones, - pitchPercent); - } - - private double calcValidPitch(final double newPitch) { - final double calcPitch = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); - - if (!isCurrentPitchControlModeSemitone()) { - return calcPitch; - } - - return PlayerSemitoneHelper.semitonesToPercent( - PlayerSemitoneHelper.percentToSemitones(calcPitch)); - } - - /*////////////////////////////////////////////////////////////////////////// - // Helper - //////////////////////////////////////////////////////////////////////////*/ - - private void updateCallback() { - if (callback == null) { - return; - } - if (DEBUG) { - Log.d(TAG, "Updating callback: " - + "tempo = " + tempo + ", " - + "pitchPercent = " + pitchPercent + ", " - + "skipSilence = " + skipSilence - ); - } - callback.onPlaybackParameterChanged((float) tempo, (float) pitchPercent, skipSilence); - } - - @NonNull - private static String getStepUpPercentString(final double percent) { - return '+' + getPercentString(percent); - } - - @NonNull - private static String getStepDownPercentString(final double percent) { - return '-' + getPercentString(percent); - } - - @NonNull - private static String getPercentString(final double percent) { - return PlayerHelper.formatPitch(percent); - } - - public interface Callback { - void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, - boolean playbackSkipSilence); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java deleted file mode 100644 index 506b643fe..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ /dev/null @@ -1,224 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; -import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; -import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; - -import java.io.File; - -public class PlayerDataSource { - public static final String TAG = PlayerDataSource.class.getSimpleName(); - - public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; - - /** - * An approximately 4.3 times greater value than the - * {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT default} - * to ensure that (very) low latency livestreams which got stuck for a moment don't crash too - * early. - */ - private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15; - - /** - * The maximum number of generated manifests per cache, in - * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and - * {@link YoutubePostLiveStreamDvrDashManifestCreator}. - */ - private static final int MAX_MANIFEST_CACHE_SIZE = 500; - - /** - * The folder name in which the ExoPlayer cache will be written. - */ - private static final String CACHE_FOLDER_NAME = "exoplayer"; - - /** - * The {@link SimpleCache} instance which will be used to build - * {@link com.google.android.exoplayer2.upstream.cache.CacheDataSource}s instances (with - * {@link CacheFactory}). - */ - private static SimpleCache cache; - - - private final int progressiveLoadIntervalBytes; - - // Generic Data Source Factories (without or with cache) - private final DataSource.Factory cachelessDataSourceFactory; - private final CacheFactory cacheDataSourceFactory; - - // YouTube-specific Data Source Factories (with cache) - // They use YoutubeHttpDataSource.Factory, with different parameters each - private final CacheFactory ytHlsCacheDataSourceFactory; - private final CacheFactory ytDashCacheDataSourceFactory; - private final CacheFactory ytProgressiveDashCacheDataSourceFactory; - - - public PlayerDataSource(final Context context, - final TransferListener transferListener) { - - progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); - - // make sure the static cache was created: needed by CacheFactories below - instantiateCacheIfNeeded(context); - - // generic data source factories use DefaultHttpDataSource.Factory - cachelessDataSourceFactory = new DefaultDataSource.Factory(context, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) - .setTransferListener(transferListener); - cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); - - // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() - ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, false)); - ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(true, true)); - ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, true)); - - // set the maximum size to manifest creators - YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); - YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); - YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( - MAX_MANIFEST_CACHE_SIZE); - } - - - //region Live media source factories - public SsMediaSource.Factory getLiveSsMediaSourceFactory() { - return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); - } - - public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { - return new HlsMediaSource.Factory(cachelessDataSourceFactory) - .setAllowChunklessPreparation(true) - .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory) -> - new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, - PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); - } - - public DashMediaSource.Factory getLiveDashMediaSourceFactory() { - return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), - cachelessDataSourceFactory); - } - - public DashMediaSource.Factory getLiveYoutubeDashMediaSourceFactory() { - return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), - cachelessDataSourceFactory) - .setManifestParser(new YoutubeDashLiveManifestParser()); - } - //endregion - - - //region Generic media source factories - public HlsMediaSource.Factory getHlsMediaSourceFactory( - @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { - if (hlsDataSourceFactoryBuilder != null) { - hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); - return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); - } - - return new HlsMediaSource.Factory(cacheDataSourceFactory); - } - - public DashMediaSource.Factory getDashMediaSourceFactory() { - return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cacheDataSourceFactory), - cacheDataSourceFactory); - } - - public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { - return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); - } - - public SsMediaSource.Factory getSSMediaSourceFactory() { - return new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), - cachelessDataSourceFactory); - } - - public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { - return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); - } - //endregion - - - //region YouTube media source factories - public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { - return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); - } - - public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { - return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), - ytDashCacheDataSourceFactory); - } - - public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { - return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); - } - //endregion - - - //region Static methods - private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( - final DataSource.Factory dataSourceFactory) { - return new DefaultDashChunkSource.Factory(dataSourceFactory); - } - - private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( - final boolean rangeParameterEnabled, - final boolean rnParameterEnabled) { - return new YoutubeHttpDataSource.Factory() - .setRangeParameterEnabled(rangeParameterEnabled) - .setRnParameterEnabled(rnParameterEnabled); - } - - private static void instantiateCacheIfNeeded(final Context context) { - if (cache == null) { - final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (DEBUG) { - Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); - } - if (!cacheDir.exists() && !cacheDir.mkdir()) { - Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); - } - - final LeastRecentlyUsedCacheEvictor evictor = - new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); - cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); - } - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java deleted file mode 100644 index 25844f799..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ /dev/null @@ -1,505 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; -import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER; -import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.provider.Settings; -import android.view.accessibility.CaptioningManager; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.SeekParameters; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.ExoTrackSelection; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; -import com.google.android.exoplayer2.ui.CaptionStyleCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.utils.Utils; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.Localization; - -import java.lang.annotation.Retention; -import java.text.DecimalFormat; -import java.text.DecimalFormatSymbols; -import java.text.NumberFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -public final class PlayerHelper { - private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider(); - - @Retention(SOURCE) - @IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI, - AUTOPLAY_TYPE_NEVER}) - public @interface AutoplayType { - int AUTOPLAY_TYPE_ALWAYS = 0; - int AUTOPLAY_TYPE_WIFI = 1; - int AUTOPLAY_TYPE_NEVER = 2; - } - - @Retention(SOURCE) - @IntDef({MINIMIZE_ON_EXIT_MODE_NONE, MINIMIZE_ON_EXIT_MODE_BACKGROUND, - MINIMIZE_ON_EXIT_MODE_POPUP}) - public @interface MinimizeMode { - int MINIMIZE_ON_EXIT_MODE_NONE = 0; - int MINIMIZE_ON_EXIT_MODE_BACKGROUND = 1; - int MINIMIZE_ON_EXIT_MODE_POPUP = 2; - } - - private PlayerHelper() { - } - - // region Exposed helpers - - public static void resetFormat() { - FORMATTERS_PROVIDER.reset(); - } - - @NonNull - public static String getTimeString(final long milliSeconds) { - final long seconds = (milliSeconds % 60000) / 1000; - final long minutes = (milliSeconds % 3600000) / 60000; - final long hours = (milliSeconds % 86400000) / 3600000; - final long days = (milliSeconds % (86400000 * 7)) / 86400000; - - final Formatters formatters = FORMATTERS_PROVIDER.formatters(); - if (days > 0) { - return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds); - } - - return hours > 0 - ? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds) - : formatters.stringFormat("%02d:%02d", minutes, seconds); - } - - @NonNull - public static String formatSpeed(final double speed) { - return FORMATTERS_PROVIDER.formatters().speed().format(speed); - } - - @NonNull - public static String formatPitch(final double pitch) { - return FORMATTERS_PROVIDER.formatters().pitch().format(pitch); - } - - @NonNull - public static String captionLanguageOf(@NonNull final Context context, - @NonNull final SubtitlesStream subtitles) { - final String displayName = subtitles.getDisplayLanguageName(); - return displayName + (subtitles.isAutoGenerated() - ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); - } - - @NonNull - public static String captionLanguageStemOf(@NonNull final String language) { - if (!language.contains("(") || !language.contains(")")) { - return language; - } - - if (language.startsWith("(")) { - // language text is right-to-left - final String[] parts = language.split("\\)"); - return parts[parts.length - 1].trim(); - } - - return language.split("\\(")[0].trim(); - } - - @NonNull - public static String resizeTypeOf(@NonNull final Context context, - @ResizeMode final int resizeMode) { - switch (resizeMode) { - case AspectRatioFrameLayout.RESIZE_MODE_FIT: - return context.getString(R.string.resize_fit); - case AspectRatioFrameLayout.RESIZE_MODE_FILL: - return context.getString(R.string.resize_fill); - case AspectRatioFrameLayout.RESIZE_MODE_ZOOM: - return context.getString(R.string.resize_zoom); - case AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT: - case AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH: - default: - throw new IllegalArgumentException("Unrecognized resize mode: " + resizeMode); - } - } - - /** - * Given a {@link StreamInfo} and the existing queue items, - * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. - *

- * This method detects and prevents cycles by naively checking - * if a candidate next video's url already exists in the existing items. - *

- *

- * The first item in {@link StreamInfo#getRelatedItems()} is checked first. - * If it is non-null and is not part of the existing items, it will be used as the next stream. - * Otherwise, a random stream with non-repeating url will be selected - * from the {@link StreamInfo#getRelatedItems()}. Non-stream items are ignored. - *

- * - * @param info currently playing stream - * @param existingItems existing items in the queue - * @return {@link SinglePlayQueue} with the next stream to queue - */ - @Nullable - public static PlayQueue autoQueueOf(@NonNull final StreamInfo info, - @NonNull final List existingItems) { - final Set urls = existingItems.stream() - .map(PlayQueueItem::getUrl) - .collect(Collectors.toUnmodifiableSet()); - - final List relatedItems = info.getRelatedItems(); - if (Utils.isNullOrEmpty(relatedItems)) { - return null; - } - - if (relatedItems.get(0) instanceof StreamInfoItem - && !urls.contains(relatedItems.get(0).getUrl())) { - return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); - } - - final List autoQueueItems = new ArrayList<>(); - for (final InfoItem item : relatedItems) { - if (item instanceof StreamInfoItem && !urls.contains(item.getUrl())) { - autoQueueItems.add((StreamInfoItem) item); - } - } - - Collections.shuffle(autoQueueItems); - return autoQueueItems.isEmpty() - ? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)); - } - - // endregion - // region Resolution - - public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { - return getPreferences(context) - .getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), false); - } - - public static String getActionForRightGestureSide(@NonNull final Context context) { - return getPreferences(context) - .getString(context.getString(R.string.right_gesture_control_key), - context.getString(R.string.default_right_gesture_control_value)); - } - - public static String getActionForLeftGestureSide(@NonNull final Context context) { - return getPreferences(context) - .getString(context.getString(R.string.left_gesture_control_key), - context.getString(R.string.default_left_gesture_control_value)); - } - - public static boolean isStartMainPlayerFullscreenEnabled(@NonNull final Context context) { - return getPreferences(context) - .getBoolean(context.getString(R.string.start_main_player_fullscreen_key), false); - } - - public static boolean isAutoQueueEnabled(@NonNull final Context context) { - return getPreferences(context) - .getBoolean(context.getString(R.string.auto_queue_key), false); - } - - public static boolean isClearingQueueConfirmationRequired(@NonNull final Context context) { - return getPreferences(context) - .getBoolean(context.getString(R.string.clear_queue_confirmation_key), false); - } - - @MinimizeMode - public static int getMinimizeOnExitAction(@NonNull final Context context) { - final String action = getPreferences(context) - .getString(context.getString(R.string.minimize_on_exit_key), ""); - if (action.equals(context.getString(R.string.minimize_on_exit_popup_key))) { - return MINIMIZE_ON_EXIT_MODE_POPUP; - } else if (action.equals(context.getString(R.string.minimize_on_exit_none_key))) { - return MINIMIZE_ON_EXIT_MODE_NONE; - } else { - return MINIMIZE_ON_EXIT_MODE_BACKGROUND; // default - } - } - - @AutoplayType - public static int getAutoplayType(@NonNull final Context context) { - final String type = getPreferences(context).getString( - context.getString(R.string.autoplay_key), ""); - if (type.equals(context.getString(R.string.autoplay_always_key))) { - return AUTOPLAY_TYPE_ALWAYS; - } else if (type.equals(context.getString(R.string.autoplay_never_key))) { - return AUTOPLAY_TYPE_NEVER; - } else { - return AUTOPLAY_TYPE_WIFI; // default - } - } - - public static boolean isAutoplayAllowedByUser(@NonNull final Context context) { - switch (PlayerHelper.getAutoplayType(context)) { - case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER: - return false; - case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI: - return !ListHelper.isMeteredNetwork(context); - case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS: - default: - return true; - } - } - - @NonNull - public static SeekParameters getSeekParameters(@NonNull final Context context) { - return isUsingInexactSeek(context) ? SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; - } - - public static long getPreferredCacheSize() { - return 64 * 1024 * 1024L; - } - - public static long getPreferredFileSize() { - return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE - } - - @NonNull - public static ExoTrackSelection.Factory getQualitySelector() { - return new AdaptiveTrackSelection.Factory( - 1000, - AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, - AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION); - } - - @NonNull - public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) { - final CaptioningManager captioningManager = ContextCompat.getSystemService(context, - CaptioningManager.class); - if (captioningManager == null || !captioningManager.isEnabled()) { - return CaptionStyleCompat.DEFAULT; - } - - return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); - } - - /** - * Get scaling for captions based on system font scaling. - *

Options:

- *
    - *
  • Very small: 0.25f
  • - *
  • Small: 0.5f
  • - *
  • Normal: 1.0f
  • - *
  • Large: 1.5f
  • - *
  • Very large: 2.0f
  • - *
- * - * @param context Android app context - * @return caption scaling - */ - public static float getCaptionScale(@NonNull final Context context) { - final CaptioningManager captioningManager = ContextCompat.getSystemService(context, - CaptioningManager.class); - if (captioningManager == null || !captioningManager.isEnabled()) { - return 1.0f; - } - - return captioningManager.getFontScale(); - } - - /** - * @param context the Android context - * @return the screen brightness to use. A value less than 0 (the default) means to use the - * preferred screen brightness - */ - public static float getScreenBrightness(@NonNull final Context context) { - final SharedPreferences sp = getPreferences(context); - final long timestamp = - sp.getLong(context.getString(R.string.screen_brightness_timestamp_key), 0); - // Hypothesis: 4h covers a viewing block, e.g. evening. - // External lightning conditions will change in the next - // viewing block so we fall back to the default brightness - if ((System.currentTimeMillis() - timestamp) > TimeUnit.HOURS.toMillis(4)) { - return -1; - } else { - return sp.getFloat(context.getString(R.string.screen_brightness_key), -1); - } - } - - public static void setScreenBrightness(@NonNull final Context context, - final float screenBrightness) { - getPreferences(context).edit() - .putFloat(context.getString(R.string.screen_brightness_key), screenBrightness) - .putLong(context.getString(R.string.screen_brightness_timestamp_key), - System.currentTimeMillis()) - .apply(); - } - - public static boolean globalScreenOrientationLocked(final Context context) { - // 1: Screen orientation changes using accelerometer - // 0: Screen orientation is locked - // if the accelerometer sensor is missing completely, assume locked orientation - return android.provider.Settings.System.getInt( - context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0 - || !context.getPackageManager() - .hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER); - } - - public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) { - final String preferredIntervalBytes = getPreferences(context).getString( - context.getString(R.string.progressive_load_interval_key), - context.getString(R.string.progressive_load_interval_default_value)); - - if (context.getString(R.string.progressive_load_interval_exoplayer_default_value) - .equals(preferredIntervalBytes)) { - return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; - } - // Keeping the same KiB unit used by ProgressiveMediaSource - return Integer.parseInt(preferredIntervalBytes) * 1024; - } - - // endregion - // region Private helpers - - @NonNull - private static SharedPreferences getPreferences(@NonNull final Context context) { - return PreferenceManager.getDefaultSharedPreferences(context); - } - - private static boolean isUsingInexactSeek(@NonNull final Context context) { - return getPreferences(context) - .getBoolean(context.getString(R.string.use_inexact_seek_key), false); - } - - private static SinglePlayQueue getAutoQueuedSinglePlayQueue( - final StreamInfoItem streamInfoItem) { - final SinglePlayQueue singlePlayQueue = new SinglePlayQueue(streamInfoItem); - Objects.requireNonNull(singlePlayQueue.getItem()).setAutoQueued(true); - return singlePlayQueue; - } - - // endregion - // region Utils used by player - - @ResizeMode - public static int retrieveResizeModeFromPrefs(final Player player) { - return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode), - AspectRatioFrameLayout.RESIZE_MODE_FIT); - } - - @SuppressLint("SwitchIntDef") // only fit, fill and zoom are supported by NewPipe - @ResizeMode - public static int nextResizeModeAndSaveToPrefs(final Player player, - @ResizeMode final int resizeMode) { - final int newResizeMode; - switch (resizeMode) { - case AspectRatioFrameLayout.RESIZE_MODE_FIT: - newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; - break; - case AspectRatioFrameLayout.RESIZE_MODE_FILL: - newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; - break; - case AspectRatioFrameLayout.RESIZE_MODE_ZOOM: - default: - newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; - break; - } - - // save the new resize mode so it can be restored in a future session - player.getPrefs().edit().putInt( - player.getContext().getString(R.string.last_resize_mode), newResizeMode).apply(); - return newResizeMode; - } - - public static PlaybackParameters retrievePlaybackParametersFromPrefs(final Player player) { - final float speed = player.getPrefs().getFloat(player.getContext().getString( - R.string.playback_speed_key), player.getPlaybackSpeed()); - final float pitch = player.getPrefs().getFloat(player.getContext().getString( - R.string.playback_pitch_key), player.getPlaybackPitch()); - return new PlaybackParameters(speed, pitch); - } - - public static void savePlaybackParametersToPrefs(final Player player, - final float speed, - final float pitch, - final boolean skipSilence) { - player.getPrefs().edit() - .putFloat(player.getContext().getString(R.string.playback_speed_key), speed) - .putFloat(player.getContext().getString(R.string.playback_pitch_key), pitch) - .putBoolean(player.getContext().getString(R.string.playback_skip_silence_key), - skipSilence) - .apply(); - } - - public static float getMinimumVideoHeight(final float width) { - return width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have - } - - public static int retrieveSeekDurationFromPreferences(final Player player) { - return Integer.parseInt(Objects.requireNonNull(player.getPrefs().getString( - player.getContext().getString(R.string.seek_duration_key), - player.getContext().getString(R.string.seek_duration_default_value)))); - } - - // endregion - // region Format - - static class FormattersProvider { - private Formatters formatters; - - public Formatters formatters() { - if (formatters == null) { - formatters = Formatters.create(); - } - return formatters; - } - - public void reset() { - formatters = null; - } - } - - record Formatters( - Locale locale, - NumberFormat speed, - NumberFormat pitch) { - - static Formatters create() { - final Locale locale = Localization.getAppLocale(); - final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale); - return new Formatters( - locale, - new DecimalFormat("0.##x", dfs), - new DecimalFormat("##%", dfs)); - } - - String stringFormat(final String format, final Object... args) { - return String.format(locale, format, args); - } - } - - // endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java deleted file mode 100644 index daae6d54e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ /dev/null @@ -1,372 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.event.PlayerServiceEventListener; -import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerHolder { - - private PlayerHolder() { - } - - private static PlayerHolder instance; - public static synchronized PlayerHolder getInstance() { - if (PlayerHolder.instance == null) { - PlayerHolder.instance = new PlayerHolder(); - } - return PlayerHolder.instance; - } - - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = PlayerHolder.class.getSimpleName(); - - @Nullable private PlayerServiceExtendedEventListener listener; - - private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); - private boolean bound; - @Nullable private PlayerService playerService; - - private Optional getPlayer() { - return Optional.ofNullable(playerService) - .flatMap(s -> Optional.ofNullable(s.getPlayer())); - } - - private Optional getPlayQueue() { - // player play queue might be null e.g. while player is starting - return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue())); - } - - /** - * Returns the current {@link PlayerType} of the {@link PlayerService} service, - * otherwise `null` if no service is running. - * - * @return Current PlayerType - */ - @Nullable - public PlayerType getType() { - return getPlayer().map(Player::getPlayerType).orElse(null); - } - - public boolean isPlaying() { - return getPlayer().map(Player::isPlaying).orElse(false); - } - - public boolean isPlayerOpen() { - return getPlayer().isPresent(); - } - - /** - * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via - * the stream long press menu) when there actually is a play queue to manipulate. - * @return true only if the player is open and its play queue is ready (i.e. it is not null) - */ - public boolean isPlayQueueReady() { - return getPlayQueue().isPresent(); - } - - public boolean isBound() { - return bound; - } - - public int getQueueSize() { - return getPlayQueue().map(PlayQueue::size).orElse(0); - } - - public int getQueuePosition() { - return getPlayQueue().map(PlayQueue::getIndex).orElse(0); - } - - public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { - listener = newListener; - - if (listener == null) { - return; - } - - // Force reload data from service - if (playerService != null) { - listener.onServiceConnected(playerService); - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - } - } - - // helper to handle context in common place as using the same - // context to bind/unbind a service is crucial - private Context getCommonContext() { - return App.getInstance(); - } - - public void startService(final boolean playAfterConnect, - final PlayerServiceExtendedEventListener newListener) { - if (DEBUG) { - Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect); - } - final Context context = getCommonContext(); - setListener(newListener); - if (bound) { - return; - } - // startService() can be called concurrently and it will give a random crashes - // and NullPointerExceptions inside the service because the service will be - // bound twice. Prevent it with unbinding first - unbind(context); - final Intent intent = new Intent(context, PlayerService.class); - intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true); - ContextCompat.startForegroundService(context, intent); - serviceConnection.doPlayAfterConnect(playAfterConnect); - bind(context); - } - - public void stopService() { - if (DEBUG) { - Log.d(TAG, "stopService() called"); - } - if (playerService != null) { - playerService.destroyPlayerAndStopService(); - } - final Context context = getCommonContext(); - unbind(context); - // destroyPlayerAndStopService() already runs the next line of code, but run it again just - // to make sure to stop the service even if playerService is null by any chance. - context.stopService(new Intent(context, PlayerService.class)); - } - - class PlayerServiceConnection implements ServiceConnection { - - private boolean playAfterConnect = false; - - /** - * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link - * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it - * is called. The value of `playAfterConnect` will be reset to false after that. - */ - public void doPlayAfterConnect(final boolean playAfterConnection) { - this.playAfterConnect = playAfterConnection; - } - - @Override - public void onServiceDisconnected(final ComponentName compName) { - if (DEBUG) { - Log.d(TAG, "Player service is disconnected"); - } - - final Context context = getCommonContext(); - unbind(context); - } - - @Override - public void onServiceConnected(final ComponentName compName, final IBinder service) { - if (DEBUG) { - Log.d(TAG, "Player service is connected"); - } - final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; - - playerService = localBinder.getService(); - if (listener != null) { - listener.onServiceConnected(playerService); - } - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - - if (playerService != null && playerService.getPlayer() != null) { - // notify the main activity that binding the service has completed and that there is - // a player, so that it can open the bottom mini-player - NavigationHelper.sendPlayerStartedEvent(localBinder.getService()); - } - } - } - - private void bind(final Context context) { - if (DEBUG) { - Log.d(TAG, "bind() called"); - } - // BIND_AUTO_CREATE starts the service if it's not already running - bound = bind(context, Context.BIND_AUTO_CREATE); - if (!bound) { - context.unbindService(serviceConnection); - } - } - - public void tryBindIfNeeded(final Context context) { - if (!bound) { - // flags=0 means the service will not be started if it does not already exist. In this - // case the return value is not useful, as a value of "true" does not really indicate - // that the service is going to be bound. - bind(context, 0); - } - } - - private boolean bind(final Context context, final int flags) { - final Intent serviceIntent = new Intent(context, PlayerService.class); - serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - return context.bindService(serviceIntent, serviceConnection, flags); - } - - private void unbind(final Context context) { - if (DEBUG) { - Log.d(TAG, "unbind() called"); - } - - if (bound) { - context.unbindService(serviceConnection); - bound = false; - stopPlayerListener(); - playerService = null; - if (listener != null) { - listener.onPlayerDisconnected(); - listener.onServiceDisconnected(); - } - } - } - - private void startPlayerListener() { - if (playerService != null) { - // setting the player listener will take care of calling relevant callbacks if the - // player in the service is (not) already active, also see playerStateListener below - playerService.setPlayerListener(playerStateListener); - } - getPlayer().ifPresent(p -> p.setFragmentListener(internalListener)); - } - - private void stopPlayerListener() { - if (playerService != null) { - playerService.setPlayerListener(null); - } - getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener)); - } - - /** - * This listener will be held by the players created by {@link PlayerService}. - */ - private final PlayerServiceEventListener internalListener = - new PlayerServiceEventListener() { - @Override - public void onViewCreated() { - if (listener != null) { - listener.onViewCreated(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - if (listener != null) { - listener.onFullscreenStateChanged(fullscreen); - } - } - - @Override - public void onScreenRotationButtonClicked() { - if (listener != null) { - listener.onScreenRotationButtonClicked(); - } - } - - @Override - public void onMoreOptionsLongClicked() { - if (listener != null) { - listener.onMoreOptionsLongClicked(); - } - } - - @Override - public void onPlayerError(final PlaybackException error, - final boolean isCatchableException) { - if (listener != null) { - listener.onPlayerError(error, isCatchableException); - } - } - - @Override - public void hideSystemUiIfNeeded() { - if (listener != null) { - listener.hideSystemUiIfNeeded(); - } - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - if (listener != null) { - listener.onQueueUpdate(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - if (listener != null) { - listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters); - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - if (listener != null) { - listener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - if (listener != null) { - listener.onMetadataUpdate(info, queue); - } - } - - @Override - public void onServiceStopped() { - if (listener != null) { - listener.onServiceStopped(); - } - unbind(getCommonContext()); - } - }; - - /** - * This listener will be held by bound {@link PlayerService}s to notify of the player starting - * or stopping. This is necessary since the service outlives the player e.g. to answer Android - * Auto media browser queries. - */ - private final Consumer playerStateListener = (@Nullable final Player player) -> { - if (listener != null) { - if (player == null) { - // player.fragmentListener=null is already done by player.stopActivityBinding(), - // which is called by player.destroy(), which is in turn called by PlayerService - // before setting its player to null - listener.onPlayerDisconnected(); - } else { - listener.onPlayerConnected(player, serviceConnection.playAfterConnect); - // reset the value of playAfterConnect: if it was true before, it is now "consumed" - serviceConnection.playAfterConnect = false; - player.setFragmentListener(internalListener); - } - } - }; -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java deleted file mode 100644 index f1ba90f8e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import androidx.core.math.MathUtils; - -/** - * Converts between percent and 12-tone equal temperament semitones. - *
- * @see - * - * Wikipedia: Equal temperament#Twelve-tone equal temperament - * - */ -public final class PlayerSemitoneHelper { - public static final int SEMITONE_COUNT = 12; - - private PlayerSemitoneHelper() { - // No impl - } - - public static String formatPitchSemitones(final double percent) { - return formatPitchSemitones(percentToSemitones(percent)); - } - - public static String formatPitchSemitones(final int semitones) { - return semitones > 0 ? "+" + semitones : "" + semitones; - } - - public static double semitonesToPercent(final int semitones) { - return Math.pow(2, ensureSemitonesInRange(semitones) / (double) SEMITONE_COUNT); - } - - public static int percentToSemitones(final double percent) { - return ensureSemitonesInRange( - (int) Math.round(SEMITONE_COUNT * Math.log(percent) / Math.log(2))); - } - - private static int ensureSemitonesInRange(final int semitones) { - return MathUtils.clamp(semitones, -SEMITONE_COUNT, SEMITONE_COUNT); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java b/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java deleted file mode 100644 index 00f5de071..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.dash.manifest.Period; -import com.google.android.exoplayer2.source.dash.manifest.ProgramInformation; -import com.google.android.exoplayer2.source.dash.manifest.ServiceDescriptionElement; -import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; - -import java.util.List; - -/** - * A {@link DashManifestParser} fixing YouTube DASH manifests to allow starting playback from the - * newest period available instead of the earliest one in some cases. - * - *

- * It changes the {@code availabilityStartTime} passed to a custom value doing the workaround. - * A better approach to fix the issue should be investigated and used in the future. - *

- */ -public class YoutubeDashLiveManifestParser extends DashManifestParser { - - // Result of Util.parseXsDateTime("1970-01-01T00:00:00Z") - private static final long AVAILABILITY_START_TIME_TO_USE = 0; - - // There is no computation made with the availabilityStartTime value in the - // parseMediaPresentationDescription method itself, so we can just override methods called in - // this method using the workaround value - // Overriding parsePeriod does not seem to be needed - - @SuppressWarnings("checkstyle:ParameterNumber") - @NonNull - @Override - protected DashManifest buildMediaPresentationDescription( - final long availabilityStartTime, - final long durationMs, - final long minBufferTimeMs, - final boolean dynamic, - final long minUpdateTimeMs, - final long timeShiftBufferDepthMs, - final long suggestedPresentationDelayMs, - final long publishTimeMs, - @Nullable final ProgramInformation programInformation, - @Nullable final UtcTimingElement utcTiming, - @Nullable final ServiceDescriptionElement serviceDescription, - @Nullable final Uri location, - @NonNull final List periods) { - return super.buildMediaPresentationDescription( - AVAILABILITY_START_TIME_TO_USE, - durationMs, - minBufferTimeMs, - dynamic, - minUpdateTimeMs, - timeShiftBufferDepthMs, - suggestedPresentationDelayMs, - publishTimeMs, - programInformation, - utcTiming, - serviceDescription, - location, - periods); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt deleted file mode 100644 index fb879bd7c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.schabi.newpipe.player.mediabrowser - -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.extractor.InfoItem.InfoType -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException - -internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID -internal const val ID_ROOT = "//$ID_AUTHORITY" -internal const val ID_BOOKMARKS = "playlists" -internal const val ID_HISTORY = "history" -internal const val ID_INFO_ITEM = "item" - -internal const val ID_LOCAL = "local" -internal const val ID_REMOTE = "remote" -internal const val ID_URL = "url" -internal const val ID_STREAM = "stream" -internal const val ID_PLAYLIST = "playlist" -internal const val ID_CHANNEL = "channel" - -internal fun infoItemTypeToString(type: InfoType): String { - return when (type) { - InfoType.STREAM -> ID_STREAM - InfoType.PLAYLIST -> ID_PLAYLIST - InfoType.CHANNEL -> ID_CHANNEL - else -> error("Unexpected value: $type") - } -} - -internal fun infoItemTypeFromString(type: String): InfoType { - return when (type) { - ID_STREAM -> InfoType.STREAM - ID_PLAYLIST -> InfoType.PLAYLIST - ID_CHANNEL -> InfoType.CHANNEL - else -> error("Unexpected value: $type") - } -} - -internal fun parseError(mediaId: String): ContentNotAvailableException { - return ContentNotAvailableException("Failed to parse media ID $mediaId") -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt deleted file mode 100644 index 6b59f683a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt +++ /dev/null @@ -1,423 +0,0 @@ -package org.schabi.newpipe.player.mediabrowser - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.util.Log -import androidx.annotation.DrawableRes -import androidx.core.net.toUri -import androidx.media.MediaBrowserServiceCompat -import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT -import androidx.media.MediaBrowserServiceCompat.Result -import androidx.media.utils.MediaConstants -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers -import java.util.function.Consumer -import org.schabi.newpipe.MainActivity.DEBUG -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.R -import org.schabi.newpipe.database.history.model.StreamHistoryEntry -import org.schabi.newpipe.database.playlist.PlaylistLocalItem -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.InfoItem.InfoType -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem -import org.schabi.newpipe.extractor.search.SearchInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.local.bookmark.MergedPlaylistManager -import org.schabi.newpipe.local.playlist.LocalPlaylistManager -import org.schabi.newpipe.local.playlist.RemotePlaylistManager -import org.schabi.newpipe.util.ExtractorHelper -import org.schabi.newpipe.util.ServiceHelper -import org.schabi.newpipe.util.image.ImageStrategy - -/** - * This class is used to cleanly separate the Service implementation (in - * [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file). - * - * @param notifyChildrenChanged takes the parent id of the children that changed - */ -class MediaBrowserImpl( - private val context: Context, - // parentId - notifyChildrenChanged: Consumer -) { - private val packageValidator = PackageValidator(context) - private val database = NewPipeDatabase.getInstance(context) - private var disposables = CompositeDisposable() - - init { - // this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d - disposables.add( - getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) } - ) - } - - //region Cleanup - fun dispose() { - disposables.dispose() - } - //endregion - - //region onGetRoot - fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): MediaBrowserServiceCompat.BrowserRoot? { - if (DEBUG) { - Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)") - } - - if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) { - // this is a caller we can't trust (see PackageValidator's rules taken from uamp) - return null - } - - if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) { - // the system is asking for a root to do media resumption, but we can't handle that yet, - // see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation - return null - } - - val extras = Bundle() - extras.putBoolean( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, - true - ) - return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras) - } - //endregion - - //region onLoadChildren - fun onLoadChildren(parentId: String, result: Result>) { - if (DEBUG) { - Log.d(TAG, "onLoadChildren($parentId)") - } - - result.detach() // allows sendResult() to happen later - disposables.add( - onLoadChildren(parentId) - .subscribe( - { result.sendResult(it) }, - { throwable -> - // null indicates an error, see the docs of MediaSessionCompat.onSearch() - result.sendResult(null) - Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable") - } - ) - ) - } - - private fun onLoadChildren(parentId: String): Single> { - try { - val parentIdUri = parentId.toUri() - val path = ArrayList(parentIdUri.pathSegments) - - if (path.isEmpty()) { - return Single.just( - listOf( - createRootMediaItem( - ID_BOOKMARKS, - context.resources.getString(R.string.tab_bookmarks_short), - R.drawable.ic_bookmark_white - ), - createRootMediaItem( - ID_HISTORY, - context.resources.getString(R.string.action_history), - R.drawable.ic_history_white - ) - ) - ) - } - - when (path.removeAt(0)) { - ID_BOOKMARKS -> { - if (path.isEmpty()) { - return populateBookmarks() - } - if (path.size == 2) { - val localOrRemote = path[0] - val playlistId = path[1].toLong() - if (localOrRemote == ID_LOCAL) { - return populateLocalPlaylist(playlistId) - } else if (localOrRemote == ID_REMOTE) { - return populateRemotePlaylist(playlistId) - } - } - Log.w(TAG, "Unknown playlist URI: $parentId") - throw parseError(parentId) - } - - ID_HISTORY -> return populateHistory() - - else -> throw parseError(parentId) - } - } catch (e: ContentNotAvailableException) { - return Single.error(e) - } - } - - private fun createRootMediaItem( - mediaId: String?, - folderName: String?, - @DrawableRes iconResId: Int - ): MediaBrowserCompat.MediaItem { - val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(mediaId) - builder.setTitle(folderName) - val resources = context.resources - builder.setIconUri( - Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(resources.getResourcePackageName(iconResId)) - .appendPath(resources.getResourceTypeName(iconResId)) - .appendPath(resources.getResourceEntryName(iconResId)) - .build() - ) - - val extras = Bundle() - extras.putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(R.string.app_name) - ) - builder.setExtras(extras) - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } - - private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { - val builder = MediaDescriptionCompat.Builder() - builder - .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) - .setTitle(playlist.orderingName) - .setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl)) - - val extras = Bundle() - extras.putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.resources.getString(R.string.tab_bookmarks) - ) - builder.setExtras(extras) - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } - - private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? { - val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForInfoItem(item)) - .setTitle(item.name) - - when (item.infoType) { - InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) - InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName) - InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description) - else -> return null - } - - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } - - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - } - - private fun buildMediaId(): Uri.Builder { - return Uri.Builder().authority(ID_AUTHORITY) - } - - private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder { - return buildMediaId() - .appendPath(ID_BOOKMARKS) - .appendPath(playlistType) - } - - private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder { - return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL) - .appendPath(playlistId.toString()) - } - - private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder { - return buildMediaId() - .appendPath(ID_INFO_ITEM) - .appendPath(infoItemTypeToString(item.infoType)) - .appendPath(item.serviceId.toString()) - .appendQueryParameter(ID_URL, item.url) - } - - private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String { - return buildLocalPlaylistItemMediaId(isRemote, playlistId) - .build().toString() - } - - private fun createLocalPlaylistStreamMediaItem( - playlistId: Long, - item: PlaylistStreamEntry, - index: Int - ): MediaBrowserCompat.MediaItem { - val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) - .setTitle(item.streamEntity.title) - .setSubtitle(item.streamEntity.uploader) - .setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl)) - - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - } - - private fun createRemotePlaylistStreamMediaItem( - playlistId: Long, - item: StreamInfoItem, - index: Int - ): MediaBrowserCompat.MediaItem { - val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) - .setTitle(item.name) - .setSubtitle(item.uploaderName) - - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } - - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - } - - private fun createMediaIdForPlaylistIndex( - isRemote: Boolean, - playlistId: Long, - index: Int - ): String { - return buildLocalPlaylistItemMediaId(isRemote, playlistId) - .appendPath(index.toString()) - .build().toString() - } - - private fun createMediaIdForInfoItem(item: InfoItem): String { - return buildInfoItemMediaId(item).build().toString() - } - - private fun populateHistory(): Single> { - val history = database.streamHistoryDAO().history.firstOrError() - return history.map { items -> - items.map { this.createHistoryMediaItem(it) } - } - } - - private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem { - val builder = MediaDescriptionCompat.Builder() - val mediaId = buildMediaId() - .appendPath(ID_HISTORY) - .appendPath(streamHistoryEntry.streamId.toString()) - .build().toString() - builder.setMediaId(mediaId) - .setTitle(streamHistoryEntry.streamEntity.title) - .setSubtitle(streamHistoryEntry.streamEntity.uploader) - .setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl)) - - return MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - } - - private fun getMergedPlaylists(): Flowable> { - return MergedPlaylistManager.getMergedOrderedPlaylists( - LocalPlaylistManager(database), - RemotePlaylistManager(database) - ) - } - - private fun populateBookmarks(): Single> { - val playlists = getMergedPlaylists().firstOrError() - return playlists.map { playlist -> - playlist.map { this.createPlaylistMediaItem(it) } - } - } - - private fun populateLocalPlaylist(playlistId: Long): Single> { - val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() - return playlist.map { items -> - items.mapIndexed { index, item -> - createLocalPlaylistStreamMediaItem(playlistId, item, index) - } - } - } - - private fun populateRemotePlaylist(playlistId: Long): Single> { - return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError() - .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) } - .map { - // ignore it.errors, i.e. ignore errors about specific items, since there would - // be no way to show the error properly in Android Auto anyway - it.relatedItems.mapIndexed { index, item -> - createRemotePlaylistStreamMediaItem(playlistId, item, index) - } - } - } - //endregion - - //region Search - fun onSearch( - query: String, - result: Result> - ) { - if (DEBUG) { - Log.d(TAG, "onSearch($query)") - } - - result.detach() // allows sendResult() to happen later - disposables.add( - searchMusicBySongTitle(query) - // ignore it.errors, i.e. ignore errors about specific items, since there would - // be no way to show the error properly in Android Auto anyway - .map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) } - .subscribeOn(Schedulers.io()) - .subscribe( - { result.sendResult(it) }, - { throwable -> - // null indicates an error, see the docs of MediaSessionCompat.onSearch() - result.sendResult(null) - Log.e(TAG, "Search error for query=\"$query\": $throwable") - } - ) - ) - } - - private fun searchMusicBySongTitle(query: String?): Single { - val serviceId = ServiceHelper.getSelectedServiceId(context) - return ExtractorHelper.searchFor(serviceId, query, listOf(), "") - } - //endregion - - companion object { - private val TAG: String = MediaBrowserImpl::class.java.getSimpleName() - - fun imageUriOrNullIfDisabled(url: String?): Uri? { - return if (ImageStrategy.shouldLoadImages()) { - url?.toUri() - } else { - null - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt deleted file mode 100644 index c0a2f9668..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ /dev/null @@ -1,263 +0,0 @@ -package org.schabi.newpipe.player.mediabrowser - -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.os.ResultReceiver -import android.support.v4.media.session.PlaybackStateCompat -import android.util.Log -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers -import java.util.function.BiConsumer -import java.util.function.Consumer -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.NewPipeDatabase -import org.schabi.newpipe.R -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.extractor.InfoItem.InfoType -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler -import org.schabi.newpipe.local.playlist.LocalPlaylistManager -import org.schabi.newpipe.local.playlist.RemotePlaylistManager -import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue -import org.schabi.newpipe.player.playqueue.PlayQueue -import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue -import org.schabi.newpipe.player.playqueue.SinglePlayQueue -import org.schabi.newpipe.util.ChannelTabHelper -import org.schabi.newpipe.util.ExtractorHelper -import org.schabi.newpipe.util.NavigationHelper - -/** - * This class is used to cleanly separate the Service implementation (in - * [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this - * file). We currently use the playback preparer only in conjunction with the media browser: the - * playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start - * playback of the corresponding streams or playlists. - * - * @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat], - * calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)` - * @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)` - * @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because - * `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of - * `player.prepare()` if the playback preparer is not null, but we want the original behavior - */ -class MediaBrowserPlaybackPreparer( - private val context: Context, - private val setMediaSessionError: BiConsumer, // error string, error code - private val clearMediaSessionError: Runnable, - private val onPrepare: Consumer -) : PlaybackPreparer { - private val database = NewPipeDatabase.getInstance(context) - private var disposable: Disposable? = null - - fun dispose() { - disposable?.dispose() - } - - //region Overrides - override fun getSupportedPrepareActions(): Long { - return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID - } - - override fun onPrepare(playWhenReady: Boolean) { - onPrepare.accept(playWhenReady) - } - - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - if (MainActivity.DEBUG) { - Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)") - } - - disposable?.dispose() - disposable = extractPlayQueueFromMediaId(mediaId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { playQueue -> - clearMediaSessionError.run() - NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady) - }, - { throwable -> - Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable) - onPrepareError(throwable) - } - ) - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - onUnsupportedError() - } - - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { - onUnsupportedError() - } - - override fun onCommand( - player: Player, - command: String, - extras: Bundle?, - cb: ResultReceiver? - ): Boolean { - return false - } - //endregion - - //region Errors - private fun onUnsupportedError() { - setMediaSessionError.accept( - ContextCompat.getString(context, R.string.content_not_supported), - PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED - ) - } - - private fun onPrepareError(throwable: Throwable) { - setMediaSessionError.accept( - ErrorInfo.getMessage(throwable, null, null).getText(context), - PlaybackStateCompat.ERROR_CODE_APP_ERROR - ) - } - //endregion - - //region Building play queues from playlists and history - private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single { - return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() - .map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) } - } - - private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single { - return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError() - .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) } - // ignore info.errors, i.e. ignore errors about specific items, since there would - // be no way to show the error properly in Android Auto anyway - .map { info -> PlaylistPlayQueue(info, index) } - } - - private fun extractPlayQueueFromMediaId(mediaId: String): Single { - try { - val mediaIdUri = mediaId.toUri() - val path = ArrayList(mediaIdUri.pathSegments) - if (path.isEmpty()) { - throw parseError(mediaId) - } - - return when (path.removeAt(0)) { - ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId( - mediaId, - path, - mediaIdUri.getQueryParameter(ID_URL) - ) - - ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path) - - ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId( - mediaId, - path, - mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId) - ) - - else -> throw parseError(mediaId) - } - } catch (e: ContentNotAvailableException) { - return Single.error(e) - } - } - - @Throws(ContentNotAvailableException::class) - private fun extractPlayQueueFromPlaylistMediaId( - mediaId: String, - path: MutableList, - url: String? - ): Single { - if (path.isEmpty()) { - throw parseError(mediaId) - } - - when (val playlistType = path.removeAt(0)) { - ID_LOCAL, ID_REMOTE -> { - if (path.size != 2) { - throw parseError(mediaId) - } - val playlistId = path[0].toLong() - val index = path[1].toInt() - return if (playlistType == ID_LOCAL) { - extractLocalPlayQueue(playlistId, index) - } else { - extractRemotePlayQueue(playlistId, index) - } - } - - ID_URL -> { - if (path.size != 1 || url == null) { - throw parseError(mediaId) - } - - val serviceId = path[0].toInt() - return ExtractorHelper.getPlaylistInfo(serviceId, url, false) - .map { PlaylistPlayQueue(it) } - } - - else -> throw parseError(mediaId) - } - } - - @Throws(ContentNotAvailableException::class) - private fun extractPlayQueueFromHistoryMediaId( - mediaId: String, - path: List - ): Single { - if (path.size != 1) { - throw parseError(mediaId) - } - - val streamId = path[0].toLong() - return database.streamHistoryDAO().history - .firstOrError() - .map { items -> - val infoItems = items - .filter { it.streamId == streamId } - .map { it.toStreamInfoItem() } - SinglePlayQueue(infoItems, 0) - } - } - - @Throws(ContentNotAvailableException::class) - private fun extractPlayQueueFromInfoItemMediaId( - mediaId: String, - path: List, - url: String - ): Single { - if (path.size != 2) { - throw parseError(mediaId) - } - - val serviceId = path[1].toInt() - return when (infoItemTypeFromString(path[0])) { - InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) - .map { SinglePlayQueue(it) } - - InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) - .map { PlaylistPlayQueue(it) } - - InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false) - .map { info -> - val playableTab = info.tabs - .firstOrNull { ChannelTabHelper.isStreamsTab(it) } - ?: throw ContentNotAvailableException("No streams tab found") - return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab)) - } - - else -> throw parseError(mediaId) - } - } - //endregion - - companion object { - private val TAG = MediaBrowserPlaybackPreparer::class.simpleName - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt deleted file mode 100644 index 619fa600a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2018 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED. -// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt - -package org.schabi.newpipe.player.mediabrowser - -import android.Manifest.permission.MEDIA_CONTENT_CONTROL -import android.annotation.SuppressLint -import android.content.Context -import android.content.pm.PackageInfo -import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED -import android.content.pm.PackageManager -import android.os.Process -import android.support.v4.media.session.MediaSessionCompat -import android.util.Log -import androidx.core.app.NotificationManagerCompat -import androidx.media.MediaBrowserServiceCompat -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import org.schabi.newpipe.BuildConfig - -/** - * Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat]. - * - * The list of allowed signing certificates and their corresponding package names is defined in - * res/xml/allowed_media_browser_callers.xml. - * - * If you want to add a new caller to allowed_media_browser_callers.xml and you don't know - * its signature, this class will print to logcat (INFO level) a message with the proper - * xml tags to add to allow the caller. - * - * For more information, see res/xml/allowed_media_browser_callers.xml. - */ -internal class PackageValidator(context: Context) { - private val context: Context = context.applicationContext - private val packageManager: PackageManager = this.context.packageManager - private val platformSignature: String = getSystemSignature() - private val callerChecked = mutableMapOf>() - - /** - * Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known. - * See [MusicService.onGetRoot] for where this is utilized. - * - * @param callingPackage The package name of the caller. - * @param callingUid The user id of the caller. - * @return `true` if the caller is known, `false` otherwise. - */ - fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean { - // If the caller has already been checked, return the previous result here. - val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false) - if (checkedUid == callingUid) { - return checkResult - } - - /** - * Because some of these checks can be slow, we save the results in [callerChecked] after - * this code is run. - * - * In particular, there's little reason to recompute the calling package's certificate - * signature (SHA-256) each call. - * - * This is safe to do as we know the UID matches the package's UID (from the check above), - * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to - * be constant until a reboot. (After a reboot then a previously assigned UID could be - * reassigned.) - */ - - // Build the caller info for the rest of the checks here. - val callerPackageInfo = buildCallerInfo(callingPackage) - ?: error("Caller wasn't found in the system?") - - // Verify that things aren't ... broken. (This test should always pass.) - check(callerPackageInfo.uid == callingUid) { - "Caller's package UID doesn't match caller's actual UID?" - } - - val callerSignature = callerPackageInfo.signature - - val isCallerKnown = when { - // If it's our own app making the call, allow it. - callingUid == Process.myUid() -> true - - // If the system is making the call, allow it. - callingUid == Process.SYSTEM_UID -> true - - // If the app was signed by the same certificate as the platform itself, also allow it. - callerSignature == platformSignature -> true - - /* - * [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and - * while it isn't required to allow these apps to connect to a - * [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps - * such as Android TV and the Google Assistant. - */ - callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true - - /* - * If the calling app has a notification listener it is able to retrieve notifications - * and can connect to an active [MediaSessionCompat]. - * - * It's not required to allow apps with a notification listener to - * connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility - * with apps such as Wear OS. - */ - NotificationManagerCompat.getEnabledListenerPackages(this.context) - .contains(callerPackageInfo.packageName) -> true - - // If none of the previous checks succeeded, then the caller is unrecognized. - else -> false - } - - if (!isCallerKnown) { - logUnknownCaller(callerPackageInfo) - } - - // Save our work for next time. - callerChecked[callingPackage] = Pair(callingUid, isCallerKnown) - return isCallerKnown - } - - /** - * Logs an info level message with details of how to add a caller to the allowed callers list - * when the app is debuggable. - */ - private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) { - if (BuildConfig.DEBUG) { - Log.w(TAG, "Unknown caller $callerPackageInfo") - } - } - - /** - * Builds a [CallerPackageInfo] for a given package that can be used for all the - * various checks that are performed before allowing an app to connect to a - * [MediaBrowserServiceCompat]. - */ - private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? { - val packageInfo = getPackageInfo(callingPackage) ?: return null - - val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString() - val uid = packageInfo.applicationInfo?.uid ?: -1 - val signature = getSignature(packageInfo) - - val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty() - val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty() - val activePermissions = (requestedPermissions zip permissionFlags) - .filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 } - .mapTo(mutableSetOf()) { (permission, flag) -> permission } - - return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet()) - } - - /** - * Looks up the [PackageInfo] for a package name. - * This requests both the signatures (for checking if an app is on the allow list) and - * the app's permissions, which allow for more flexibility in the allow list. - * - * @return [PackageInfo] for the package name or null if it's not found. - */ - @Suppress("deprecation") - @SuppressLint("PackageManagerGetSignatures") - private fun getPackageInfo(callingPackage: String): PackageInfo? = packageManager.getPackageInfo( - callingPackage, - PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS - ) - - /** - * Gets the signature of a given package's [PackageInfo]. - * - * The "signature" is a SHA-256 hash of the public key of the signing certificate used by - * the app. - * - * If the app is not found, or if the app does not have exactly one signature, this method - * returns `null` as the signature. - */ - @Suppress("deprecation") - private fun getSignature(packageInfo: PackageInfo): String? = if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) { - // Security best practices dictate that an app should be signed with exactly one (1) - // signature. Because of this, if there are multiple signatures, reject it. - null - } else { - val certificate = packageInfo.signatures!![0].toByteArray() - getSignatureSha256(certificate) - } - - /** - * Finds the Android platform signing key signature. This key is never null. - */ - private fun getSystemSignature(): String = getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo -> - getSignature(platformInfo) - } ?: error("Platform signature not found") - - /** - * Creates a SHA-256 signature given a certificate byte array. - */ - private fun getSignatureSha256(certificate: ByteArray): String { - val md: MessageDigest - try { - md = MessageDigest.getInstance("SHA256") - } catch (noSuchAlgorithmException: NoSuchAlgorithmException) { - Log.e(TAG, "No such algorithm: $noSuchAlgorithmException") - throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException) - } - md.update(certificate) - - // This code takes the byte array generated by `md.digest()` and joins each of the bytes - // to a string, applying the string format `%02x` on each digit before it's appended, with - // a colon (':') between each of the items. - // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c" - return md.digest().joinToString(":") { String.format("%02x", it) } - } - - /** - * Convenience class to hold all of the information about an app that's being checked - * to see if it's a known caller. - */ - private data class CallerPackageInfo( - val name: String, - val packageName: String, - val uid: Int, - val signature: String?, - val permissions: Set - ) -} - -private const val TAG = "PackageValidator" -private const val ANDROID_PLATFORM = "android" diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java deleted file mode 100644 index 95a4f74af..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.schabi.newpipe.player.mediaitem; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.image.ImageStrategy; - -import java.util.List; -import java.util.Optional; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * This {@link MediaItemTag} object is designed to contain metadata for a stream - * that has failed to load. It supplies metadata from an underlying - * {@link PlayQueueItem}, which is used by the internal players to resolve actual - * playback info. - * - * This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be - * used to start playback and can be detected by checking {@link ExceptionTag#getErrors()} - * when in generic form. - **/ -public final class ExceptionTag implements MediaItemTag { - @NonNull - private final PlayQueueItem item; - @NonNull - private final List errors; - @Nullable - private final Object extras; - - private ExceptionTag(@NonNull final PlayQueueItem item, - @NonNull final List errors, - @Nullable final Object extras) { - this.item = item; - this.errors = errors; - this.extras = extras; - } - - public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem, - @NonNull final List errors) { - return new ExceptionTag(playQueueItem, errors, null); - } - - @NonNull - @Override - public List getErrors() { - return errors; - } - - @Override - public int getServiceId() { - return item.getServiceId(); - } - - @Override - public String getTitle() { - return item.getTitle(); - } - - @Override - public String getUploaderName() { - return item.getUploader(); - } - - @Override - public long getDurationSeconds() { - return item.getDuration(); - } - - @Override - public String getStreamUrl() { - return item.getUrl(); - } - - @Override - public String getThumbnailUrl() { - return ImageStrategy.choosePreferredImage(item.getThumbnails()); - } - - @Override - public String getUploaderUrl() { - return item.getUploaderUrl(); - } - - @Override - public StreamType getStreamType() { - return item.getStreamType(); - } - - @Override - public Optional getMaybeExtras(@NonNull final Class type) { - return Optional.ofNullable(extras).map(type::cast); - } - - @Override - public MediaItemTag withExtras(@NonNull final T extra) { - return new ExceptionTag(item, errors, extra); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java deleted file mode 100644 index 346bb92fa..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java +++ /dev/null @@ -1,171 +0,0 @@ -package org.schabi.newpipe.player.mediaitem; - -import android.net.Uri; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.MediaItem.RequestMetadata; -import com.google.android.exoplayer2.MediaMetadata; -import com.google.android.exoplayer2.Player; - -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Metadata container and accessor used by player internals. - * - * This interface ensures consistency of fetching metadata on each stream, - * which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's - * {@link Player.Listener} on event triggers to the downstream users. - **/ -public interface MediaItemTag { - - List getErrors(); - - int getServiceId(); - - String getTitle(); - - String getUploaderName(); - - long getDurationSeconds(); - - String getStreamUrl(); - - String getThumbnailUrl(); - - String getUploaderUrl(); - - StreamType getStreamType(); - - @NonNull - default Optional getMaybeStreamInfo() { - return Optional.empty(); - } - - @NonNull - default Optional getMaybeQuality() { - return Optional.empty(); - } - - @NonNull - default Optional getMaybeAudioTrack() { - return Optional.empty(); - } - - Optional getMaybeExtras(@NonNull Class type); - - MediaItemTag withExtras(@NonNull T extra); - - @NonNull - static Optional from(@Nullable final MediaItem mediaItem) { - return Optional.ofNullable(mediaItem) - .map(item -> item.localConfiguration) - .map(localConfiguration -> localConfiguration.tag) - .filter(MediaItemTag.class::isInstance) - .map(MediaItemTag.class::cast); - } - - @NonNull - default String makeMediaId() { - return UUID.randomUUID().toString() + "[" + getTitle() + "]"; - } - - @NonNull - default MediaItem asMediaItem() { - final String thumbnailUrl = getThumbnailUrl(); - final MediaMetadata mediaMetadata = new MediaMetadata.Builder() - .setArtworkUri(thumbnailUrl == null ? null : Uri.parse(thumbnailUrl)) - .setArtist(getUploaderName()) - .setDescription(getTitle()) - .setDisplayTitle(getTitle()) - .setTitle(getTitle()) - .build(); - - final RequestMetadata requestMetaData = new RequestMetadata.Builder() - .setMediaUri(Uri.parse(getStreamUrl())) - .build(); - - return MediaItem.fromUri(getStreamUrl()) - .buildUpon() - .setMediaId(makeMediaId()) - .setMediaMetadata(mediaMetadata) - .setRequestMetadata(requestMetaData) - .setTag(this) - .build(); - } - - final class Quality { - @NonNull - private final List sortedVideoStreams; - private final int selectedVideoStreamIndex; - - private Quality(@NonNull final List sortedVideoStreams, - final int selectedVideoStreamIndex) { - this.sortedVideoStreams = sortedVideoStreams; - this.selectedVideoStreamIndex = selectedVideoStreamIndex; - } - - static Quality of(@NonNull final List sortedVideoStreams, - final int selectedVideoStreamIndex) { - return new Quality(sortedVideoStreams, selectedVideoStreamIndex); - } - - @NonNull - public List getSortedVideoStreams() { - return sortedVideoStreams; - } - - public int getSelectedVideoStreamIndex() { - return selectedVideoStreamIndex; - } - - @Nullable - public VideoStream getSelectedVideoStream() { - return selectedVideoStreamIndex < 0 - || selectedVideoStreamIndex >= sortedVideoStreams.size() - ? null : sortedVideoStreams.get(selectedVideoStreamIndex); - } - } - - final class AudioTrack { - @NonNull - private final List audioStreams; - private final int selectedAudioStreamIndex; - - private AudioTrack(@NonNull final List audioStreams, - final int selectedAudioStreamIndex) { - this.audioStreams = audioStreams; - this.selectedAudioStreamIndex = selectedAudioStreamIndex; - } - - static AudioTrack of(@NonNull final List audioStreams, - final int selectedAudioStreamIndex) { - return new AudioTrack(audioStreams, selectedAudioStreamIndex); - } - - @NonNull - public List getAudioStreams() { - return audioStreams; - } - - public int getSelectedAudioStreamIndex() { - return selectedAudioStreamIndex; - } - - @Nullable - public AudioStream getSelectedAudioStream() { - return selectedAudioStreamIndex < 0 - || selectedAudioStreamIndex >= audioStreams.size() - ? null : audioStreams.get(selectedAudioStreamIndex); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java deleted file mode 100644 index cce4e9f17..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.schabi.newpipe.player.mediaitem; - -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.util.Constants; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for - * any stream that has not been resolved. - * - * This object cannot be instantiated and does not hold real metadata of any form. - * */ -public final class PlaceholderTag implements MediaItemTag { - public static final PlaceholderTag EMPTY = new PlaceholderTag(null); - private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder"; - - @Nullable - private final Object extras; - - private PlaceholderTag(@Nullable final Object extras) { - this.extras = extras; - } - - @NonNull - @Override - public List getErrors() { - return Collections.emptyList(); - } - - @Override - public int getServiceId() { - return Constants.NO_SERVICE_ID; - } - - @Override - public String getTitle() { - return UNKNOWN_VALUE_INTERNAL; - } - - @Override - public String getUploaderName() { - return UNKNOWN_VALUE_INTERNAL; - } - - @Override - public long getDurationSeconds() { - return 0; - } - - @Override - public String getStreamUrl() { - return UNKNOWN_VALUE_INTERNAL; - } - - @Override - public String getThumbnailUrl() { - return UNKNOWN_VALUE_INTERNAL; - } - - @Override - public String getUploaderUrl() { - return UNKNOWN_VALUE_INTERNAL; - } - - @Override - public StreamType getStreamType() { - return StreamType.NONE; - } - - @Override - public Optional getMaybeExtras(@NonNull final Class type) { - return Optional.ofNullable(extras).map(type::cast); - } - - @Override - public MediaItemTag withExtras(@NonNull final T extra) { - return new PlaceholderTag(extra); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java deleted file mode 100644 index e24a93615..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.schabi.newpipe.player.mediaitem; - -import com.google.android.exoplayer2.MediaItem; - -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.util.image.ImageStrategy; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * This {@link MediaItemTag} object contains metadata for a resolved stream - * that is ready for playback. This object guarantees the {@link StreamInfo} - * is available and may provide the {@link Quality} of video stream used in - * the {@link MediaItem}. - **/ -public final class StreamInfoTag implements MediaItemTag { - @NonNull - private final StreamInfo streamInfo; - @Nullable - private final MediaItemTag.Quality quality; - @Nullable - private final MediaItemTag.AudioTrack audioTrack; - @Nullable - private final Object extras; - - private StreamInfoTag(@NonNull final StreamInfo streamInfo, - @Nullable final MediaItemTag.Quality quality, - @Nullable final MediaItemTag.AudioTrack audioTrack, - @Nullable final Object extras) { - this.streamInfo = streamInfo; - this.quality = quality; - this.audioTrack = audioTrack; - this.extras = extras; - } - - public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, - @NonNull final List sortedVideoStreams, - final int selectedVideoStreamIndex, - @NonNull final List audioStreams, - final int selectedAudioStreamIndex) { - final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex); - final AudioTrack audioTrack = - AudioTrack.of(audioStreams, selectedAudioStreamIndex); - return new StreamInfoTag(streamInfo, quality, audioTrack, null); - } - - public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, - @NonNull final List audioStreams, - final int selectedAudioStreamIndex) { - final AudioTrack audioTrack = - AudioTrack.of(audioStreams, selectedAudioStreamIndex); - return new StreamInfoTag(streamInfo, null, audioTrack, null); - } - - public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) { - return new StreamInfoTag(streamInfo, null, null, null); - } - - @Override - public List getErrors() { - return Collections.emptyList(); - } - - @Override - public int getServiceId() { - return streamInfo.getServiceId(); - } - - @Override - public String getTitle() { - return streamInfo.getName(); - } - - @Override - public String getUploaderName() { - return streamInfo.getUploaderName(); - } - - @Override - public long getDurationSeconds() { - return streamInfo.getDuration(); - } - - @Override - public String getStreamUrl() { - return streamInfo.getUrl(); - } - - @Override - public String getThumbnailUrl() { - return ImageStrategy.choosePreferredImage(streamInfo.getThumbnails()); - } - - @Override - public String getUploaderUrl() { - return streamInfo.getUploaderUrl(); - } - - @Override - public StreamType getStreamType() { - return streamInfo.getStreamType(); - } - - @NonNull - @Override - public Optional getMaybeStreamInfo() { - return Optional.of(streamInfo); - } - - @NonNull - @Override - public Optional getMaybeQuality() { - return Optional.ofNullable(quality); - } - - @NonNull - @Override - public Optional getMaybeAudioTrack() { - return Optional.ofNullable(audioTrack); - } - - @Override - public Optional getMaybeExtras(@NonNull final Class type) { - return Optional.ofNullable(extras).map(type::cast); - } - - @Override - public StreamInfoTag withExtras(@NonNull final Object extra) { - return new StreamInfoTag(streamInfo, quality, audioTrack, extra); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java deleted file mode 100644 index fe884834b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ /dev/null @@ -1,290 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.os.Build; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.media.session.MediaButtonReceiver; - -import com.google.android.exoplayer2.ForwardingPlayer; -import com.google.android.exoplayer2.Player.RepeatMode; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.notification.NotificationActionData; -import org.schabi.newpipe.player.notification.NotificationConstants; -import org.schabi.newpipe.player.ui.PlayerUi; -import org.schabi.newpipe.player.ui.VideoPlayerUi; -import org.schabi.newpipe.util.StreamTypeUtil; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -public class MediaSessionPlayerUi extends PlayerUi - implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final String TAG = "MediaSessUi"; - - @NonNull - private final MediaSessionCompat mediaSession; - @NonNull - private final MediaSessionConnector sessionConnector; - - private final String ignoreHardwareMediaButtonsKey; - private boolean shouldIgnoreHardwareMediaButtons = false; - - // used to check whether any notification action changed, before sending costly updates - private List prevNotificationActions = List.of(); - - - public MediaSessionPlayerUi(@NonNull final Player player, - @NonNull final MediaSessionCompat mediaSession, - @NonNull final MediaSessionConnector sessionConnector) { - super(player); - this.mediaSession = mediaSession; - this.sessionConnector = sessionConnector; - this.ignoreHardwareMediaButtonsKey = - context.getString(R.string.ignore_hardware_media_buttons_key); - } - - @Override - public void initPlayer() { - super.initPlayer(); - destroyPlayer(); // release previously used resources - - mediaSession.setActive(true); - - sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player)); - sessionConnector.setPlayer(getForwardingPlayer()); - - // It seems like events from the Media Control UI in the notification area don't go through - // this function, so it's safe to just ignore all events in case we want to ignore the - // hardware media buttons. Returning true stops all further event processing of the system. - sessionConnector.setMediaButtonEventHandler((p, i) -> shouldIgnoreHardwareMediaButtons); - - // listen to changes to ignore_hardware_media_buttons_key - updateShouldIgnoreHardwareMediaButtons(player.getPrefs()); - player.getPrefs().registerOnSharedPreferenceChangeListener(this); - - sessionConnector.setMetadataDeduplicationEnabled(true); - sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); - - // force updating media session actions by resetting the previous ones - prevNotificationActions = List.of(); - updateMediaSessionActions(); - } - - @Override - public void destroyPlayer() { - super.destroyPlayer(); - player.getPrefs().unregisterOnSharedPreferenceChangeListener(this); - sessionConnector.setMediaButtonEventHandler(null); - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); - mediaSession.setActive(false); - prevNotificationActions = List.of(); - } - - @Override - public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - super.onThumbnailLoaded(bitmap); - // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update - sessionConnector.invalidateMediaSessionMetadata(); - } - - - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (key == null || key.equals(ignoreHardwareMediaButtonsKey)) { - updateShouldIgnoreHardwareMediaButtons(sharedPreferences); - } - } - - public void updateShouldIgnoreHardwareMediaButtons(final SharedPreferences sharedPreferences) { - shouldIgnoreHardwareMediaButtons = - sharedPreferences.getBoolean(ignoreHardwareMediaButtonsKey, false); - } - - - public void handleMediaButtonIntent(final Intent intent) { - MediaButtonReceiver.handleIntent(mediaSession, intent); - } - - public Optional getSessionToken() { - return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); - } - - - private ForwardingPlayer getForwardingPlayer() { - // ForwardingPlayer means that all media session actions called on this player are - // forwarded directly to the connected exoplayer, except for the overridden methods. So - // override play and pause since our player adds more functionality to them over exoplayer. - return new ForwardingPlayer(player.getExoPlayer()) { - @Override - public void play() { - player.play(); - // hide the player controls even if the play command came from the media session - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); - } - - @Override - public void pause() { - player.pause(); - } - }; - } - - private MediaMetadataCompat buildMediaMetadata() { - if (DEBUG) { - Log.d(TAG, "buildMediaMetadata called"); - } - - // set title and artist - final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, player.getVideoTitle()) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, player.getUploaderName()); - - // set duration (-1 for livestreams or if unknown, see the METADATA_KEY_DURATION docs) - final long duration = player.getCurrentStreamInfo() - .filter(info -> !StreamTypeUtil.isLiveStream(info.getStreamType())) - .map(info -> info.getDuration() * 1000L) - .orElse(-1L); - builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); - - // set album art, unless the user asked not to, or there is no thumbnail available - final boolean showThumbnail = player.getPrefs().getBoolean( - context.getString(R.string.show_thumbnail_key), true); - Optional.ofNullable(player.getThumbnail()) - .filter(bitmap -> showThumbnail) - .ifPresent(bitmap -> { - builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap); - builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap); - }); - - return builder.build(); - } - - - private void updateMediaSessionActions() { - // On Android 13+ (or Android T or API 33+) the actions in the player notification can't be - // controlled directly anymore, but are instead derived from custom media session actions. - // However the system allows customizing only two of these actions, since the other three - // are fixed to play-pause-buffering, previous, next. - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - // Although setting media session actions on older android versions doesn't seem to - // cause any trouble, it also doesn't seem to do anything, so we don't do anything to - // save battery. Check out NotificationUtil.updateActions() to see what happens on - // older android versions. - return; - } - - if (!mediaSession.isActive()) { - // mediaSession will be inactive after destroyPlayer is called - return; - } - - // only use the fourth and fifth actions (the settings page also shows only the last 2 on - // Android 13+) - final List newNotificationActions = IntStream.of(3, 4) - .map(i -> player.getPrefs().getInt( - player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - NotificationConstants.SLOT_DEFAULTS[i])) - .mapToObj(action -> NotificationActionData - .fromNotificationActionEnum(player, action)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - // avoid costly notification actions update, if nothing changed from last time - if (!newNotificationActions.equals(prevNotificationActions)) { - prevNotificationActions = newNotificationActions; - sessionConnector.setCustomActionProviders( - newNotificationActions.stream() - .map(data -> new SessionConnectorActionProvider(data, context)) - .toArray(SessionConnectorActionProvider[]::new)); - } - } - - @Override - public void onBlocked() { - super.onBlocked(); - updateMediaSessionActions(); - } - - @Override - public void onPlaying() { - super.onPlaying(); - updateMediaSessionActions(); - } - - @Override - public void onBuffering() { - super.onBuffering(); - updateMediaSessionActions(); - } - - @Override - public void onPaused() { - super.onPaused(); - updateMediaSessionActions(); - } - - @Override - public void onPausedSeek() { - super.onPausedSeek(); - updateMediaSessionActions(); - } - - @Override - public void onCompleted() { - super.onCompleted(); - updateMediaSessionActions(); - } - - @Override - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - super.onRepeatModeChanged(repeatMode); - updateMediaSessionActions(); - } - - @Override - public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { - super.onShuffleModeEnabledChanged(shuffleModeEnabled); - updateMediaSessionActions(); - } - - @Override - public void onBroadcastReceived(final Intent intent) { - super.onBroadcastReceived(intent); - if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { - // the notification actions changed - updateMediaSessionActions(); - } - } - - @Override - public void onMetadataChanged(@NonNull final StreamInfo info) { - super.onMetadataChanged(info); - updateMediaSessionActions(); - } - - @Override - public void onPlayQueueEdited() { - super.onPlayQueueEdited(); - updateMediaSessionActions(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java deleted file mode 100644 index 3339869c1..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; - -import android.net.Uri; -import android.os.Bundle; -import android.os.ResultReceiver; -import android.support.v4.media.MediaDescriptionCompat; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.util.Util; - -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.image.ImageStrategy; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { - private static final int MAX_QUEUE_SIZE = 10; - - private final MediaSessionCompat mediaSession; - private final Player player; - - private long activeQueueItemId; - - public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, - @NonNull final Player player) { - this.mediaSession = mediaSession; - this.player = player; - - this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; - } - - @Override - public long getSupportedQueueNavigatorActions( - @Nullable final com.google.android.exoplayer2.Player exoPlayer) { - return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; - } - - @Override - public void onTimelineChanged(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { - publishFloatingQueueWindow(); - } - - @Override - public void onCurrentMediaItemIndexChanged( - @NonNull final com.google.android.exoplayer2.Player exoPlayer) { - if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID - || exoPlayer.getCurrentTimeline().getWindowCount() > MAX_QUEUE_SIZE) { - publishFloatingQueueWindow(); - } else if (!exoPlayer.getCurrentTimeline().isEmpty()) { - activeQueueItemId = exoPlayer.getCurrentMediaItemIndex(); - } - } - - @Override - public long getActiveQueueItemId( - @Nullable final com.google.android.exoplayer2.Player exoPlayer) { - return Optional.ofNullable(player.getPlayQueue()).map(PlayQueue::getIndex).orElse(-1); - } - - @Override - public void onSkipToPrevious(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { - player.playPrevious(); - } - - @Override - public void onSkipToQueueItem(@NonNull final com.google.android.exoplayer2.Player exoPlayer, - final long id) { - if (player.getPlayQueue() != null) { - player.selectQueueItem(player.getPlayQueue().getItem((int) id)); - } - } - - @Override - public void onSkipToNext(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { - player.playNext(); - } - - private void publishFloatingQueueWindow() { - final int windowCount = Optional.ofNullable(player.getPlayQueue()) - .map(PlayQueue::size) - .orElse(0); - if (windowCount == 0) { - mediaSession.setQueue(Collections.emptyList()); - activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; - return; - } - - // Yes this is almost a copypasta, got a problem with that? =\ - final int currentWindowIndex = player.getPlayQueue().getIndex(); - final int queueSize = Math.min(MAX_QUEUE_SIZE, windowCount); - final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, - windowCount - queueSize); - - final List queue = new ArrayList<>(); - for (int i = startIndex; i < startIndex + queueSize; i++) { - queue.add(new MediaSessionCompat.QueueItem(getQueueMetadata(i), i)); - } - mediaSession.setQueue(queue); - activeQueueItemId = currentWindowIndex; - } - - public MediaDescriptionCompat getQueueMetadata(final int index) { - if (player.getPlayQueue() == null) { - return null; - } - final PlayQueueItem item = player.getPlayQueue().getItem(index); - if (item == null) { - return null; - } - - final MediaDescriptionCompat.Builder descBuilder = new MediaDescriptionCompat.Builder() - .setMediaId(String.valueOf(index)) - .setTitle(item.getTitle()) - .setSubtitle(item.getUploader()); - - // set additional metadata for A2DP/AVRCP (Audio/Video Bluetooth profiles) - final Bundle additionalMetadata = new Bundle(); - additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); - additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); - additionalMetadata - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); - additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1L); - additionalMetadata - .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); - descBuilder.setExtras(additionalMetadata); - - try { - descBuilder.setIconUri(Uri.parse( - ImageStrategy.choosePreferredImage(item.getThumbnails()))); - } catch (final Throwable e) { - // no thumbnail available at all, or the user disabled image loading, - // or the obtained url is not a valid `Uri` - } - - return descBuilder.build(); - } - - @Override - public boolean onCommand(@NonNull final com.google.android.exoplayer2.Player exoPlayer, - @NonNull final String command, - @Nullable final Bundle extras, - @Nullable final ResultReceiver cb) { - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java deleted file mode 100644 index a5c9fccc9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.support.v4.media.session.PlaybackStateCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.player.notification.NotificationActionData; - -import java.lang.ref.WeakReference; - -public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider { - - private final NotificationActionData data; - @NonNull - private final WeakReference context; - - public SessionConnectorActionProvider(final NotificationActionData notificationActionData, - @NonNull final Context context) { - this.data = notificationActionData; - this.context = new WeakReference<>(context); - } - - @Override - public void onCustomAction(@NonNull final Player player, - @NonNull final String action, - @Nullable final Bundle extras) { - final Context actualContext = context.get(); - if (actualContext != null) { - actualContext.sendBroadcast(new Intent(action)); - } - } - - @Nullable - @Override - public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) { - return new PlaybackStateCompat.CustomAction.Builder( - data.action(), data.name(), data.icon() - ).build(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java deleted file mode 100644 index b9ca90d89..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ /dev/null @@ -1,210 +0,0 @@ -package org.schabi.newpipe.player.mediasource; - -import android.util.Log; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.BaseMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.SilenceMediaSource; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; -import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; - -import org.schabi.newpipe.player.mediaitem.ExceptionTag; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource { - /** - * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue, - * such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}. - * - * This silence duration allows user to react and have time to jump to a previous stream, - * while still provide a smooth playback experience. A duration lower than 1 second is - * not recommended, it may cause ExoPlayer to buffer for a while. - * */ - public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2); - public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US); - - private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); - private final PlayQueueItem playQueueItem; - private final Exception error; - private final long retryTimestamp; - private final MediaItem mediaItem; - /** - * Fail the play queue item associated with this source, with potential future retries. - * - * The error will be propagated if the cause for load exception is unspecified. - * This means the error might be caused by reasons outside of extraction (e.g. no network). - * Otherwise, a silenced stream will play instead. - * - * @param playQueueItem play queue item - * @param error exception that was the reason to fail - * @param retryTimestamp epoch timestamp when this MediaSource can be refreshed - */ - public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final Exception error, - final long retryTimestamp) { - this.playQueueItem = playQueueItem; - this.error = error; - this.retryTimestamp = retryTimestamp; - this.mediaItem = ExceptionTag.of(playQueueItem, List.of(error)).withExtras(this) - .asMediaItem(); - } - - public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, - @NonNull final FailedMediaSourceException error) { - return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE); - } - - public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, - @NonNull final Exception error, - final long retryWaitMillis) { - return new FailedMediaSource(playQueueItem, error, - System.currentTimeMillis() + retryWaitMillis); - } - - public PlayQueueItem getStream() { - return playQueueItem; - } - - public Exception getError() { - return error; - } - - private boolean canRetry() { - return System.currentTimeMillis() >= retryTimestamp; - } - - @Override - public MediaItem getMediaItem() { - return mediaItem; - } - - /** - * Prepares the source with {@link Timeline} info on the silence playback when the error - * is classed as {@link FailedMediaSourceException}, for example, when the error is - * {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}. - * These types of error are swallowed by {@link FailedMediaSource}, and the underlying - * exception is carried to the {@link MediaItem} metadata during playback. - *

- * If the exception is not known, e.g. {@link java.net.UnknownHostException} or some - * other network issue, then no source info is refreshed and - * {@link #maybeThrowSourceInfoRefreshError()} be will triggered. - *

- * Note that this method is called only once until {@link #releaseSourceInternal()} is called, - * so if no action is done in here, playback will stall unless - * {@link #maybeThrowSourceInfoRefreshError()} is called. - * - * @param mediaTransferListener No data transfer listener needed, ignored here. - */ - @Override - protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { - Log.e(TAG, "Loading failed source: ", error); - if (error instanceof FailedMediaSourceException) { - refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem)); - } - } - - /** - * If the error is not known, e.g. network issue, then the exception is not swallowed here in - * {@link FailedMediaSource}. The exception is then propagated to the player, which - * {@link org.schabi.newpipe.player.Player Player} can react to inside - * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}. - * - * @throws IOException An error which will always result in - * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}. - */ - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - if (!(error instanceof FailedMediaSourceException)) { - throw new IOException(error); - } - } - - /** - * This method is only called if {@link #prepareSourceInternal(TransferListener)} - * refreshes the source info with no exception. All parameters are ignored as this - * returns a static and reused piece of silent audio. - * - * @param id The identifier of the period. - * @param allocator An {@link Allocator} from which to obtain media buffer allocations. - * @param startPositionUs The expected start position, in microseconds. - * @return The common {@link MediaPeriod} holding the silence. - */ - @Override - public MediaPeriod createPeriod(final MediaPeriodId id, - final Allocator allocator, - final long startPositionUs) { - return SILENT_MEDIA; - } - - @Override - public void releasePeriod(final MediaPeriod mediaPeriod) { - /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */ - } - - @Override - protected void releaseSourceInternal() { - /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */ - } - - @Override - public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, - final boolean isInterruptable) { - return newIdentity != playQueueItem || canRetry(); - } - - @Override - public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { - return playQueueItem == stream; - } - - public static class FailedMediaSourceException extends Exception { - FailedMediaSourceException(final String message) { - super(message); - } - - FailedMediaSourceException(final Throwable cause) { - super(cause); - } - } - - public static final class MediaSourceResolutionException extends FailedMediaSourceException { - public MediaSourceResolutionException(final String message) { - super(message); - } - } - - public static final class StreamInfoLoadException extends FailedMediaSourceException { - public StreamInfoLoadException(final Throwable cause) { - super(cause); - } - } - - private static Timeline makeSilentMediaTimeline(final long durationUs, - @NonNull final MediaItem mediaItem) { - return new SinglePeriodTimeline( - durationUs, - /* isSeekable= */ true, - /* isDynamic= */ false, - /* useLiveConfiguration= */ false, - /* manifest= */ null, - mediaItem); - } - - private static MediaPeriod makeSilentMediaPeriod(final long durationUs) { - return new SilenceMediaSource.Factory() - .setDurationUs(durationUs) - .createMediaSource() - .createPeriod(null, null, 0); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java deleted file mode 100644 index 3bf7c09d9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.schabi.newpipe.player.mediasource; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.WrappingMediaSource; - -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -public class LoadedMediaSource extends WrappingMediaSource implements ManagedMediaSource { - private final PlayQueueItem stream; - private final MediaItem mediaItem; - private final long expireTimestamp; - - /** - * Uses a {@link WrappingMediaSource} to wrap one child {@link MediaSource}s - * containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration - * timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under - * {@link ManagedMediaSourcePlaylist}. - * - * @param source The child media source with actual media. - * @param tag Metadata for the child media source. - * @param stream The queue item associated with the media source. - * @param expireTimestamp The timestamp when the media source expires and might not be - * available for playback. - */ - public LoadedMediaSource(@NonNull final MediaSource source, - @NonNull final MediaItemTag tag, - @NonNull final PlayQueueItem stream, - final long expireTimestamp) { - super(source); - this.stream = stream; - this.expireTimestamp = expireTimestamp; - - this.mediaItem = tag.withExtras(this).asMediaItem(); - } - - public PlayQueueItem getStream() { - return stream; - } - - private boolean isExpired() { - return System.currentTimeMillis() >= expireTimestamp; - } - - @NonNull - @Override - public MediaItem getMediaItem() { - return mediaItem; - } - - @Override - public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, - final boolean isInterruptable) { - return newIdentity != stream || (isInterruptable && isExpired()); - } - - @Override - public boolean isStreamEqual(@NonNull final PlayQueueItem otherStream) { - return this.stream == otherStream; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java deleted file mode 100644 index 9d6b94893..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.schabi.newpipe.player.mediasource; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.source.MediaSource; - -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -public interface ManagedMediaSource extends MediaSource { - /** - * Determines whether or not this {@link ManagedMediaSource} can be replaced. - * - * @param newIdentity a stream the {@link ManagedMediaSource} should encapsulate over, if - * it is different from the existing stream in the - * {@link ManagedMediaSource}, then it should be replaced. - * @param isInterruptable specifies if this {@link ManagedMediaSource} potentially - * being played. - * @return whether this could be replaces - */ - boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, boolean isInterruptable); - - /** - * Determines if the {@link PlayQueueItem} is the one the - * {@link ManagedMediaSource} encapsulates over. - * - * @param stream play queue item to check - * @return whether this source is for the specified stream - */ - boolean isStreamEqual(@NonNull PlayQueueItem stream); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java deleted file mode 100644 index 4c0380767..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java +++ /dev/null @@ -1,178 +0,0 @@ -package org.schabi.newpipe.player.mediasource; - -import android.os.Handler; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ShuffleOrder; - -import org.schabi.newpipe.player.mediaitem.MediaItemTag; - -public class ManagedMediaSourcePlaylist { - @NonNull - private final ConcatenatingMediaSource internalSource; - - public ManagedMediaSourcePlaylist() { - internalSource = new ConcatenatingMediaSource(/*isPlaylistAtomic=*/false, - new ShuffleOrder.UnshuffledShuffleOrder(0)); - } - - /*////////////////////////////////////////////////////////////////////////// - // MediaSource Delegations - //////////////////////////////////////////////////////////////////////////*/ - - public int size() { - return internalSource.getSize(); - } - - /** - * Returns the {@link ManagedMediaSource} at the given index of the playlist. - * If the index is invalid, then null is returned. - * - * @param index index of {@link ManagedMediaSource} to get from the playlist - * @return the {@link ManagedMediaSource} at the given index of the playlist - */ - @Nullable - public ManagedMediaSource get(final int index) { - if (index < 0 || index >= size()) { - return null; - } - - return MediaItemTag - .from(internalSource.getMediaSource(index).getMediaItem()) - .flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class)) - .orElse(null); - } - - @NonNull - public ConcatenatingMediaSource getParentMediaSource() { - return internalSource; - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist Manipulation - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Expands the {@link ConcatenatingMediaSource} by appending it with a - * {@link PlaceholderMediaSource}. - * - * @see #append(ManagedMediaSource) - */ - public synchronized void expand() { - append(PlaceholderMediaSource.COPY); - } - - /** - * Appends a {@link ManagedMediaSource} to the end of {@link ConcatenatingMediaSource}. - * - * @see ConcatenatingMediaSource#addMediaSource - * @param source {@link ManagedMediaSource} to append - */ - public synchronized void append(@NonNull final ManagedMediaSource source) { - internalSource.addMediaSource(source); - } - - /** - * Removes a {@link ManagedMediaSource} from {@link ConcatenatingMediaSource} - * at the given index. If this index is out of bound, then the removal is ignored. - * - * @see ConcatenatingMediaSource#removeMediaSource(int) - * @param index of {@link ManagedMediaSource} to be removed - */ - public synchronized void remove(final int index) { - if (index < 0 || index > internalSource.getSize()) { - return; - } - - internalSource.removeMediaSource(index); - } - - /** - * Moves a {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} - * from the given source index to the target index. If either index is out of bound, - * then the call is ignored. - * - * @see ConcatenatingMediaSource#moveMediaSource(int, int) - * @param source original index of {@link ManagedMediaSource} - * @param target new index of {@link ManagedMediaSource} - */ - public synchronized void move(final int source, final int target) { - if (source < 0 || target < 0) { - return; - } - if (source >= internalSource.getSize() || target >= internalSource.getSize()) { - return; - } - - internalSource.moveMediaSource(source, target); - } - - /** - * Invalidates the {@link ManagedMediaSource} at the given index by replacing it - * with a {@link PlaceholderMediaSource}. - * - * @see #update(int, ManagedMediaSource, Handler, Runnable) - * @param index index of {@link ManagedMediaSource} to invalidate - * @param handler the {@link Handler} to run {@code finalizingAction} - * @param finalizingAction a {@link Runnable} which is executed immediately - * after the media source has been removed from the playlist - */ - public synchronized void invalidate(final int index, - @Nullable final Handler handler, - @Nullable final Runnable finalizingAction) { - if (get(index) == PlaceholderMediaSource.COPY) { - return; - } - update(index, PlaceholderMediaSource.COPY, handler, finalizingAction); - } - - /** - * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} - * at the given index with a given {@link ManagedMediaSource}. - * - * @see #update(int, ManagedMediaSource, Handler, Runnable) - * @param index index of {@link ManagedMediaSource} to update - * @param source new {@link ManagedMediaSource} to use - */ - public synchronized void update(final int index, @NonNull final ManagedMediaSource source) { - update(index, source, null, /*doNothing=*/null); - } - - /** - * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} - * at the given index with a given {@link ManagedMediaSource}. If the index is out of bound, - * then the replacement is ignored. - * - * @see ConcatenatingMediaSource#addMediaSource - * @see ConcatenatingMediaSource#removeMediaSource(int, Handler, Runnable) - * @param index index of {@link ManagedMediaSource} to update - * @param source new {@link ManagedMediaSource} to use - * @param handler the {@link Handler} to run {@code finalizingAction} - * @param finalizingAction a {@link Runnable} which is executed immediately - * after the media source has been removed from the playlist - */ - public synchronized void update(final int index, @NonNull final ManagedMediaSource source, - @Nullable final Handler handler, - @Nullable final Runnable finalizingAction) { - if (index < 0 || index >= internalSource.getSize()) { - return; - } - - // Add and remove are sequential on the same thread, therefore here, the exoplayer - // message queue must receive and process add before remove, effectively treating them - // as atomic. - - // Since the finalizing action occurs strictly after the timeline has completed - // all its changes on the playback thread, thus, it is possible, in the meantime, - // other calls that modifies the playlist media source occur in between. This makes - // it unsafe to call remove as the finalizing action of add. - internalSource.addMediaSource(index + 1, source); - - // Because of the above race condition, it is thus only safe to synchronize the player - // in the finalizing action AFTER the removal is complete and the timeline has changed. - internalSource.removeMediaSource(index, handler, finalizingAction); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java deleted file mode 100644 index 92d4403c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.player.mediasource; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.CompositeMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; - -import org.schabi.newpipe.player.mediaitem.PlaceholderTag; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -import androidx.annotation.NonNull; - -final class PlaceholderMediaSource - extends CompositeMediaSource implements ManagedMediaSource { - public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource(); - private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem(); - - private PlaceholderMediaSource() { } - - @Override - public MediaItem getMediaItem() { - return MEDIA_ITEM; - } - - @Override - protected void onChildSourceInfoRefreshed(final Void id, - final MediaSource mediaSource, - final Timeline timeline) { - /* Do nothing, no timeline updates or error will stall playback */ - } - - @Override - public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, - final long startPositionUs) { - return null; - } - - @Override - public void releasePeriod(final MediaPeriod mediaPeriod) { } - - @Override - public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, - final boolean isInterruptable) { - return true; - } - - @Override - public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java deleted file mode 100644 index 17ae732c6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java +++ /dev/null @@ -1,205 +0,0 @@ -package org.schabi.newpipe.player.notification; - -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.player.Player; - -import java.util.Objects; - -public final class NotificationActionData { - - @NonNull - private final String action; - @NonNull - private final String name; - @DrawableRes - private final int icon; - - - public NotificationActionData(@NonNull final String action, @NonNull final String name, - @DrawableRes final int icon) { - this.action = action; - this.name = name; - this.icon = icon; - } - - @NonNull - public String action() { - return action; - } - - @NonNull - public String name() { - return name; - } - - @DrawableRes - public int icon() { - return icon; - } - - - @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons - @Nullable - public static NotificationActionData fromNotificationActionEnum( - @NonNull final Player player, - @NotificationConstants.Action final int selectedAction - ) { - - final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction]; - final Context ctx = player.getContext(); - - switch (selectedAction) { - case NotificationConstants.PREVIOUS: - return new NotificationActionData(ACTION_PLAY_PREVIOUS, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_previous_description), baseActionIcon); - - case NotificationConstants.NEXT: - return new NotificationActionData(ACTION_PLAY_NEXT, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_next_description), baseActionIcon); - - case NotificationConstants.REWIND: - return new NotificationActionData(ACTION_FAST_REWIND, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_rewind_description), baseActionIcon); - - case NotificationConstants.FORWARD: - return new NotificationActionData(ACTION_FAST_FORWARD, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_fastforward_description), baseActionIcon); - - case NotificationConstants.SMART_REWIND_PREVIOUS: - if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return new NotificationActionData(ACTION_PLAY_PREVIOUS, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_previous_description), - com.google.android.exoplayer2.ui.R.drawable.exo_notification_previous); - } else { - return new NotificationActionData(ACTION_FAST_REWIND, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_rewind_description), - com.google.android.exoplayer2.ui.R.drawable.exo_controls_rewind); - } - - case NotificationConstants.SMART_FORWARD_NEXT: - if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return new NotificationActionData(ACTION_PLAY_NEXT, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_next_description), - com.google.android.exoplayer2.ui.R.drawable.exo_notification_next); - } else { - return new NotificationActionData(ACTION_FAST_FORWARD, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_fastforward_description), - com.google.android.exoplayer2.ui.R.drawable.exo_controls_fastforward); - } - - case NotificationConstants.PLAY_PAUSE_BUFFERING: - if (player.getCurrentState() == Player.STATE_PREFLIGHT - || player.getCurrentState() == Player.STATE_BLOCKED - || player.getCurrentState() == Player.STATE_BUFFERING) { - return new NotificationActionData(ACTION_PLAY_PAUSE, - ctx.getString(R.string.notification_action_buffering), - R.drawable.ic_hourglass_top); - } - - // fallthrough - case NotificationConstants.PLAY_PAUSE: - if (player.getCurrentState() == Player.STATE_COMPLETED) { - return new NotificationActionData(ACTION_PLAY_PAUSE, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_pause_description), - R.drawable.ic_replay); - } else if (player.isPlaying() - || player.getCurrentState() == Player.STATE_PREFLIGHT - || player.getCurrentState() == Player.STATE_BLOCKED - || player.getCurrentState() == Player.STATE_BUFFERING) { - return new NotificationActionData(ACTION_PLAY_PAUSE, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_pause_description), - com.google.android.exoplayer2.ui.R.drawable.exo_notification_pause); - } else { - return new NotificationActionData(ACTION_PLAY_PAUSE, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_play_description), - com.google.android.exoplayer2.ui.R.drawable.exo_notification_play); - } - - case NotificationConstants.REPEAT: - if (player.getRepeatMode() == REPEAT_MODE_ALL) { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_repeat_all_description), - com.google.android.exoplayer2.ext.mediasession.R.drawable - .exo_media_action_repeat_all); - } else if (player.getRepeatMode() == REPEAT_MODE_ONE) { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_repeat_one_description), - com.google.android.exoplayer2.ext.mediasession.R.drawable - .exo_media_action_repeat_one); - } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_repeat_off_description), - com.google.android.exoplayer2.ext.mediasession.R.drawable - .exo_media_action_repeat_off); - } - - case NotificationConstants.SHUFFLE: - if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { - return new NotificationActionData(ACTION_SHUFFLE, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_shuffle_on_description), - com.google.android.exoplayer2.ui.R.drawable.exo_controls_shuffle_on); - } else { - return new NotificationActionData(ACTION_SHUFFLE, - ctx.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_shuffle_off_description), - com.google.android.exoplayer2.ui.R.drawable.exo_controls_shuffle_off); - } - - case NotificationConstants.CLOSE: - return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close), - R.drawable.ic_close); - - case NotificationConstants.NOTHING: - default: - // do nothing - return null; - } - } - - - @Override - public boolean equals(@Nullable final Object obj) { - return (obj instanceof NotificationActionData other) - && this.action.equals(other.action) - && this.name.equals(other.name) - && this.icon == other.icon; - } - - @Override - public int hashCode() { - return Objects.hash(action, name, icon); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java deleted file mode 100644 index 4f304b405..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java +++ /dev/null @@ -1,198 +0,0 @@ -package org.schabi.newpipe.player.notification; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.DrawableRes; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.Localization; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.Collection; -import java.util.List; -import java.util.SortedSet; -import java.util.TreeSet; - -public final class NotificationConstants { - - private NotificationConstants() { - } - - - - /*////////////////////////////////////////////////////////////////////////// - // Intent actions - //////////////////////////////////////////////////////////////////////////*/ - - private static final String BASE_ACTION = - App.PACKAGE_NAME + ".player.MainPlayer."; - public static final String ACTION_CLOSE = - BASE_ACTION + "CLOSE"; - public static final String ACTION_PLAY_PAUSE = - BASE_ACTION + ".player.MainPlayer.PLAY_PAUSE"; - public static final String ACTION_REPEAT = - BASE_ACTION + ".player.MainPlayer.REPEAT"; - public static final String ACTION_PLAY_NEXT = - BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_NEXT"; - public static final String ACTION_PLAY_PREVIOUS = - BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_PREVIOUS"; - public static final String ACTION_FAST_REWIND = - BASE_ACTION + ".player.MainPlayer.ACTION_FAST_REWIND"; - public static final String ACTION_FAST_FORWARD = - BASE_ACTION + ".player.MainPlayer.ACTION_FAST_FORWARD"; - public static final String ACTION_SHUFFLE = - BASE_ACTION + ".player.MainPlayer.ACTION_SHUFFLE"; - public static final String ACTION_RECREATE_NOTIFICATION = - BASE_ACTION + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION"; - - - public static final int NOTHING = 0; - public static final int PREVIOUS = 1; - public static final int NEXT = 2; - public static final int REWIND = 3; - public static final int FORWARD = 4; - public static final int SMART_REWIND_PREVIOUS = 5; - public static final int SMART_FORWARD_NEXT = 6; - public static final int PLAY_PAUSE = 7; - public static final int PLAY_PAUSE_BUFFERING = 8; - public static final int REPEAT = 9; - public static final int SHUFFLE = 10; - public static final int CLOSE = 11; - - @Retention(RetentionPolicy.SOURCE) - @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, - SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, - SHUFFLE, CLOSE}) - public @interface Action { } - - @Action - public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, - SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, - SHUFFLE, CLOSE}; - - @DrawableRes - public static final int[] ACTION_ICONS = { - 0, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_previous, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_next, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_rewind, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_fastforward, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_previous, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_next, - R.drawable.ic_pause, - R.drawable.ic_hourglass_top, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_repeat_all, - com.google.android.exoplayer2.ui.R.drawable.exo_icon_shuffle_on, - R.drawable.ic_close, - }; - - - @Action - public static final int[] SLOT_DEFAULTS = { - SMART_REWIND_PREVIOUS, - PLAY_PAUSE_BUFFERING, - SMART_FORWARD_NEXT, - REPEAT, - CLOSE, - }; - - public static final int[] SLOT_PREF_KEYS = { - R.string.notification_slot_0_key, - R.string.notification_slot_1_key, - R.string.notification_slot_2_key, - R.string.notification_slot_3_key, - R.string.notification_slot_4_key, - }; - - - public static final List SLOT_COMPACT_DEFAULTS = List.of(0, 1, 2); - - public static final int[] SLOT_COMPACT_PREF_KEYS = { - R.string.notification_slot_compact_0_key, - R.string.notification_slot_compact_1_key, - R.string.notification_slot_compact_2_key, - }; - - - public static String getActionName(@NonNull final Context context, @Action final int action) { - switch (action) { - case PREVIOUS: - return context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_previous_description); - case NEXT: - return context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_next_description); - case REWIND: - return context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_rewind_description); - case FORWARD: - return context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_fastforward_description); - case SMART_REWIND_PREVIOUS: - return Localization.concatenateStrings( - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_rewind_description), - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_previous_description)); - case SMART_FORWARD_NEXT: - return Localization.concatenateStrings( - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_fastforward_description), - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_next_description)); - case PLAY_PAUSE: - return Localization.concatenateStrings( - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_play_description), - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_pause_description)); - case PLAY_PAUSE_BUFFERING: - return Localization.concatenateStrings( - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_play_description), - context.getString(com.google.android.exoplayer2.ui.R.string - .exo_controls_pause_description), - context.getString(R.string.notification_action_buffering)); - case REPEAT: - return context.getString(R.string.notification_action_repeat); - case SHUFFLE: - return context.getString(R.string.notification_action_shuffle); - case CLOSE: - return context.getString(R.string.close); - case NOTHING: default: - return context.getString(R.string.notification_action_nothing); - } - } - - - /** - * @param context the context to use - * @param sharedPreferences the shared preferences to query values from - * @return a sorted list of the indices of the slots to use as compact slots - */ - public static Collection getCompactSlotsFromPreferences( - @NonNull final Context context, - final SharedPreferences sharedPreferences) { - final SortedSet compactSlots = new TreeSet<>(); - for (int i = 0; i < 3; i++) { - final int compactSlot = sharedPreferences.getInt( - context.getString(SLOT_COMPACT_PREF_KEYS[i]), Integer.MAX_VALUE); - - if (compactSlot == Integer.MAX_VALUE) { - // settings not yet populated, return default values - return SLOT_COMPACT_DEFAULTS; - } - - if (compactSlot >= 0) { - // compact slot is < 0 if there are less than 3 checked checkboxes - compactSlots.add(compactSlot); - } - } - return compactSlots; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java deleted file mode 100644 index 75b27545c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java +++ /dev/null @@ -1,119 +0,0 @@ -package org.schabi.newpipe.player.notification; - -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; - -import android.content.Intent; -import android.graphics.Bitmap; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player.RepeatMode; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.ui.PlayerUi; - -public final class NotificationPlayerUi extends PlayerUi { - private final NotificationUtil notificationUtil; - - public NotificationPlayerUi(@NonNull final Player player) { - super(player); - notificationUtil = new NotificationUtil(player); - } - - @Override - public void destroy() { - super.destroy(); - notificationUtil.cancelNotificationAndStopForeground(); - } - - @Override - public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - super.onThumbnailLoaded(bitmap); - notificationUtil.updateThumbnail(); - } - - @Override - public void onBlocked() { - super.onBlocked(); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onPlaying() { - super.onPlaying(); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onBuffering() { - super.onBuffering(); - if (notificationUtil.shouldUpdateBufferingSlot()) { - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - } - - @Override - public void onPaused() { - super.onPaused(); - - // Remove running notification when user does not want minimization to background or popup - if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE - && player.videoPlayerSelected()) { - notificationUtil.cancelNotificationAndStopForeground(); - } else { - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - } - - @Override - public void onPausedSeek() { - super.onPausedSeek(); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onCompleted() { - super.onCompleted(); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - super.onRepeatModeChanged(repeatMode); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { - super.onShuffleModeEnabledChanged(shuffleModeEnabled); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - @Override - public void onBroadcastReceived(final Intent intent) { - super.onBroadcastReceived(intent); - if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { - notificationUtil.createNotificationIfNeededAndUpdate(true); - } - } - - @Override - public void onMetadataChanged(@NonNull final StreamInfo info) { - super.onMetadataChanged(info); - notificationUtil.createNotificationIfNeededAndUpdate(true); - } - - @Override - public void onPlayQueueEdited() { - super.onPlayQueueEdited(); - notificationUtil.createNotificationIfNeededAndUpdate(false); - } - - public void createNotificationAndStartForeground() { - notificationUtil.createNotificationAndStartForeground(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java deleted file mode 100644 index be98abb7a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ /dev/null @@ -1,326 +0,0 @@ -package org.schabi.newpipe.player.notification; - -import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; -import static androidx.media.app.NotificationCompat.MediaStyle; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; - -import android.annotation.SuppressLint; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ServiceInfo; -import android.graphics.Bitmap; -import android.os.Build; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.PendingIntentCompat; -import androidx.core.app.ServiceCompat; -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerIntentType; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * This is a utility class for player notifications. - */ -public final class NotificationUtil { - private static final String TAG = NotificationUtil.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - private static final int NOTIFICATION_ID = 123789; - - @NotificationConstants.Action - private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone(); - - private NotificationManagerCompat notificationManager; - private NotificationCompat.Builder notificationBuilder; - - private final Player player; - - public NotificationUtil(final Player player) { - this.player = player; - } - - - ///////////////////////////////////////////////////// - // NOTIFICATION - ///////////////////////////////////////////////////// - - /** - * Creates the notification if it does not exist already and recreates it if forceRecreate is - * true. Updates the notification with the data in the player. - * @param forceRecreate whether to force the recreation of the notification even if it already - * exists - */ - public synchronized void createNotificationIfNeededAndUpdate(final boolean forceRecreate) { - if (forceRecreate || notificationBuilder == null) { - notificationBuilder = createNotification(); - } - updateNotification(); - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); - } - } - - public synchronized void updateThumbnail() { - if (notificationBuilder != null) { - if (DEBUG) { - Log.d(TAG, "updateThumbnail() called with thumbnail = [" + Integer.toHexString( - Optional.ofNullable(player.getThumbnail()).map(Objects::hashCode).orElse(0)) - + "], title = [" + player.getVideoTitle() + "]"); - } - - setLargeIcon(notificationBuilder); - if (notificationManager.areNotificationsEnabled()) { - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); - } - } - } - - private synchronized NotificationCompat.Builder createNotification() { - if (DEBUG) { - Log.d(TAG, "createNotification()"); - } - notificationManager = NotificationManagerCompat.from(player.getContext()); - - // setup media style (compact notification slots and media session) - final MediaStyle mediaStyle = new MediaStyle(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - // notification actions are ignored on Android 13+, and are replaced by code in - // MediaSessionPlayerUi - final int[] compactSlots = initializeNotificationSlots(); - mediaStyle.setShowActionsInCompactView(compactSlots); - } - player.UIs() - .get(MediaSessionPlayerUi.class) - .flatMap(MediaSessionPlayerUi::getSessionToken) - .ifPresent(mediaStyle::setMediaSession); - - // setup notification builder - final var builder = setupNotificationBuilder(player.getContext(), mediaStyle) - .setColorized(player.getPrefs().getBoolean( - player.getContext().getString(R.string.notification_colorize_key), true)); - - // set the initial value for the video thumbnail, updatable with updateNotificationThumbnail - setLargeIcon(builder); - - return builder; - } - - /** - * Updates the notification builder and the button icons depending on the playback state. - */ - private synchronized void updateNotification() { - if (DEBUG) { - Log.d(TAG, "updateNotification()"); - } - - // also update content intent, in case the user switched players - notificationBuilder.setContentIntent(PendingIntentCompat.getActivity(player.getContext(), - NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT, false)); - notificationBuilder.setContentTitle(player.getVideoTitle()); - notificationBuilder.setContentText(player.getUploaderName()); - notificationBuilder.setTicker(player.getVideoTitle()); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - // notification actions are ignored on Android 13+, and are replaced by code in - // MediaSessionPlayerUi - updateActions(notificationBuilder); - } - } - - - @SuppressLint("RestrictedApi") - public boolean shouldUpdateBufferingSlot() { - if (notificationBuilder == null) { - // if there is no notification active, there is no point in updating it - return false; - } else if (notificationBuilder.mActions.size() < 3) { - // this should never happen, but let's make sure notification actions are populated - return true; - } - - // only second and third slot could contain PLAY_PAUSE_BUFFERING, update them only if they - // are not already in the buffering state (the only one with a null action intent) - return (notificationSlots[1] == NotificationConstants.PLAY_PAUSE_BUFFERING - && notificationBuilder.mActions.get(1).actionIntent != null) - || (notificationSlots[2] == NotificationConstants.PLAY_PAUSE_BUFFERING - && notificationBuilder.mActions.get(2).actionIntent != null); - } - - public static void startForegroundWithDummyNotification(final PlayerService service) { - final var builder = setupNotificationBuilder(service, new MediaStyle()); - startForeground(service, builder.build()); - } - - public void createNotificationAndStartForeground() { - if (notificationBuilder == null) { - notificationBuilder = createNotification(); - } - updateNotification(); - startForeground(player.getService(), notificationBuilder.build()); - } - - public void cancelNotificationAndStopForeground() { - ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE); - - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - notificationManager = null; - notificationBuilder = null; - } - - - ///////////////////////////////////////////////////// - // STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION - ///////////////////////////////////////////////////// - - private static NotificationCompat.Builder setupNotificationBuilder(final Context context, - final MediaStyle style) { - return new NotificationCompat.Builder(context, - context.getString(R.string.notification_channel_id)) - .setStyle(style) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setShowWhen(false) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setColor(ContextCompat.getColor(context, R.color.dark_background_color)) - .setDeleteIntent(PendingIntentCompat.getBroadcast(context, - NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); - } - - private static void startForeground(final PlayerService service, - final Notification notification) { - // ServiceInfo constants are not used below Android Q, so 0 is set here - final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0; - ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType); - } - - - ///////////////////////////////////////////////////// - // ACTIONS - ///////////////////////////////////////////////////// - - /** - * The compact slots array from settings contains indices from 0 to 4, each referring to one of - * the five actions configurable by the user. However, if the user sets an action to "Nothing", - * then all of the actions coming after will have a "settings index" different than the index - * of the corresponding action when sent to the system. - * - * @return the indices of compact slots referred to the list of non-nothing actions that will be - * sent to the system - */ - private int[] initializeNotificationSlots() { - final Collection settingsCompactSlots = NotificationConstants - .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs()); - final List adjustedCompactSlots = new ArrayList<>(); - - int nonNothingIndex = 0; - for (int i = 0; i < 5; ++i) { - notificationSlots[i] = player.getPrefs().getInt( - player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - NotificationConstants.SLOT_DEFAULTS[i]); - - if (notificationSlots[i] != NotificationConstants.NOTHING) { - if (settingsCompactSlots.contains(i)) { - adjustedCompactSlots.add(nonNothingIndex); - } - nonNothingIndex += 1; - } - } - - return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray(); - } - - @SuppressLint("RestrictedApi") - private void updateActions(final NotificationCompat.Builder builder) { - builder.mActions.clear(); - for (int i = 0; i < 5; ++i) { - addAction(builder, notificationSlots[i]); - } - } - - private void addAction(final NotificationCompat.Builder builder, - @NotificationConstants.Action final int slot) { - @Nullable final NotificationActionData data = - NotificationActionData.fromNotificationActionEnum(player, slot); - if (data == null) { - return; - } - - final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(), - NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false); - builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent)); - } - - private Intent getIntentForNotification() { - if (player.audioPlayerSelected() || player.popupPlayerSelected()) { - // Means we play in popup or audio only. Let's show the play queue - return NavigationHelper.getPlayQueueActivityIntent(player.getContext()); - } else { - // We are playing in fragment. Don't open another activity just show fragment. That's it - final Intent intent = NavigationHelper.getPlayerIntent( - player.getContext(), MainActivity.class, null, - PlayerIntentType.AllOthers); - intent.putExtra(Player.RESUME_PLAYBACK, true); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.setAction(Intent.ACTION_MAIN); - intent.addCategory(Intent.CATEGORY_LAUNCHER); - return intent; - } - } - - - ///////////////////////////////////////////////////// - // BITMAP - ///////////////////////////////////////////////////// - - private void setLargeIcon(final NotificationCompat.Builder builder) { - final boolean showThumbnail = player.getPrefs().getBoolean( - player.getContext().getString(R.string.show_thumbnail_key), true); - final Bitmap thumbnail = player.getThumbnail(); - if (thumbnail == null || !showThumbnail) { - // since the builder is reused, make sure the thumbnail is unset if there is not one - builder.setLargeIcon((Bitmap) null); - return; - } - - final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean( - player.getContext().getString(R.string.scale_to_square_image_in_notifications_key), - false); - if (scaleImageToSquareAspectRatio) { - builder.setLargeIcon(getBitmapWithSquareAspectRatio(thumbnail)); - } else { - builder.setLargeIcon(thumbnail); - } - } - - private Bitmap getBitmapWithSquareAspectRatio(@NonNull final Bitmap bitmap) { - // Find the smaller dimension and then take a center portion of the image that - // has that size. - final int w = bitmap.getWidth(); - final int h = bitmap.getHeight(); - final int dstSize = Math.min(w, h); - final int x = (w - dstSize) / 2; - final int y = (h - dstSize) / 2; - return Bitmap.createBitmap(bitmap, x, y, dstSize, dstSize); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java deleted file mode 100644 index 5cffc7f62..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ /dev/null @@ -1,608 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.os.Handler; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.ArraySet; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.mediasource.FailedMediaSource; -import org.schabi.newpipe.player.mediasource.LoadedMediaSource; -import org.schabi.newpipe.player.mediasource.ManagedMediaSource; -import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent; - -import java.util.Collection; -import java.util.Collections; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; - -import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; -import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; -import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; -import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; - -public class MediaSourceManager { - @NonNull - private final String TAG = "MediaSourceManager@" + hashCode(); - - /** - * Determines how many streams before and after the current stream should be loaded. - * The default value (1) ensures seamless playback under typical network settings. - *

- * The streams after the current will be loaded into the playlist timeline while the - * streams before will only be cached for future usage. - *

- * - * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) - */ - private static final int WINDOW_SIZE = 1; - - /** - * Determines the maximum number of disposables allowed in the {@link #loaderReactor}. - * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the - * {@link #loaderReactor} in order to load a new set of items. - * - * @see #loadImmediate() - * @see #maybeLoadItem(PlayQueueItem) - */ - private static final int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; - - @NonNull - private final PlaybackListener playbackListener; - @NonNull - private final PlayQueue playQueue; - - /** - * Determines the gap time between the playback position and the playback duration which - * the {@link #getEdgeIntervalSignal()} begins to request loading. - * - * @see #progressUpdateIntervalMillis - */ - private final long playbackNearEndGapMillis; - - /** - * Determines the interval which the {@link #getEdgeIntervalSignal()} waits for between - * each request for loading, once {@link #playbackNearEndGapMillis} has reached. - */ - private final long progressUpdateIntervalMillis; - - @NonNull - private final Observable nearEndIntervalSignal; - - /** - * Process only the last load order when receiving a stream of load orders (lessens I/O). - *

- * The higher it is, the less loading occurs during rapid noncritical timeline changes. - *

- *

- * Not recommended to go below 100ms. - *

- * - * @see #loadDebounced() - */ - private final long loadDebounceMillis; - - @NonNull - private final Disposable debouncedLoader; - @NonNull - private final PublishSubject debouncedSignal; - - @NonNull - private Subscription playQueueReactor; - - @NonNull - private final CompositeDisposable loaderReactor; - @NonNull - private final Set loadingItems; - - @NonNull - private final AtomicBoolean isBlocked; - - @NonNull - private ManagedMediaSourcePlaylist playlist; - - private final Handler removeMediaSourceHandler = new Handler(); - - public MediaSourceManager(@NonNull final PlaybackListener listener, - @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 400L, - /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), - /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); - } - - private MediaSourceManager(@NonNull final PlaybackListener listener, - @NonNull final PlayQueue playQueue, - final long loadDebounceMillis, - final long playbackNearEndGapMillis, - final long progressUpdateIntervalMillis) { - if (playQueue.getBroadcastReceiver() == null) { - throw new IllegalArgumentException("Play Queue has not been initialized."); - } - if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { - throw new IllegalArgumentException("Playback end gap=[" + playbackNearEndGapMillis - + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis - + " ms] for them to be useful."); - } - - this.playbackListener = listener; - this.playQueue = playQueue; - - this.playbackNearEndGapMillis = playbackNearEndGapMillis; - this.progressUpdateIntervalMillis = progressUpdateIntervalMillis; - this.nearEndIntervalSignal = getEdgeIntervalSignal(); - - this.loadDebounceMillis = loadDebounceMillis; - this.debouncedSignal = PublishSubject.create(); - this.debouncedLoader = getDebouncedLoader(); - - this.playQueueReactor = EmptySubscription.INSTANCE; - this.loaderReactor = new CompositeDisposable(); - - this.isBlocked = new AtomicBoolean(false); - - this.playlist = new ManagedMediaSourcePlaylist(); - - this.loadingItems = Collections.synchronizedSet(new ArraySet<>()); - - playQueue.getBroadcastReceiver() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getReactor()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Exposed Methods - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Dispose the manager and releases all message buses and loaders. - */ - public void dispose() { - if (DEBUG) { - Log.d(TAG, "close() called."); - } - - debouncedSignal.onComplete(); - debouncedLoader.dispose(); - - playQueueReactor.cancel(); - loaderReactor.dispose(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Event Reactor - //////////////////////////////////////////////////////////////////////////*/ - - private Subscriber getReactor() { - return new Subscriber<>() { - @Override - public void onSubscribe(@NonNull final Subscription d) { - playQueueReactor.cancel(); - playQueueReactor = d; - playQueueReactor.request(1); - } - - @Override - public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { - onPlayQueueChanged(playQueueMessage); - } - - @Override - public void onError(@NonNull final Throwable e) { - } - - @Override - public void onComplete() { - } - }; - } - - private void onPlayQueueChanged(final PlayQueueEvent event) { - if (playQueue.isEmpty() && playQueue.isComplete()) { - playbackListener.onPlaybackShutdown(); - return; - } - - // Event specific action - switch (event.type()) { - case INIT: - case ERROR: - maybeBlock(); - case APPEND: - populateSources(); - break; - case SELECT: - maybeRenewCurrentIndex(); - break; - case REMOVE: - final RemoveEvent removeEvent = (RemoveEvent) event; - playlist.remove(removeEvent.getRemoveIndex()); - break; - case MOVE: - final MoveEvent moveEvent = (MoveEvent) event; - playlist.move(moveEvent.getFromIndex(), moveEvent.getToIndex()); - break; - case REORDER: - // Need to move to ensure the playing index from play queue matches that of - // the source timeline, and then window correction can take care of the rest - final ReorderEvent reorderEvent = (ReorderEvent) event; - playlist.move(reorderEvent.getFromSelectedIndex(), - reorderEvent.getToSelectedIndex()); - break; - case RECOVERY: - default: - break; - } - - // Loading and Syncing - switch (event.type()) { - case INIT: case REORDER: case ERROR: case SELECT: - loadImmediate(); // low frequency, critical events - break; - case APPEND: case REMOVE: case MOVE: case RECOVERY: - default: - loadDebounced(); // high frequency or noncritical events - break; - } - - // update ui and notification - switch (event.type()) { - case APPEND: case REMOVE: case MOVE: case REORDER: - playbackListener.onPlayQueueEdited(); - } - - if (!isPlayQueueReady()) { - maybeBlock(); - playQueue.fetch(); - } - playQueueReactor.request(1); - } - - /*////////////////////////////////////////////////////////////////////////// - // Playback Locking - //////////////////////////////////////////////////////////////////////////*/ - - private boolean isPlayQueueReady() { - final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; - return playQueue.isComplete() || isWindowLoaded; - } - - private boolean isPlaybackReady() { - if (playlist.size() != playQueue.size()) { - return false; - } - - final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); - final PlayQueueItem playQueueItem = playQueue.getItem(); - if (mediaSource == null || playQueueItem == null) { - return false; - } - - return mediaSource.isStreamEqual(playQueueItem); - } - - private void maybeBlock() { - if (DEBUG) { - Log.d(TAG, "maybeBlock() called."); - } - - if (isBlocked.get()) { - return; - } - - playbackListener.onPlaybackBlock(); - resetSources(); - - isBlocked.set(true); - } - - private boolean maybeUnblock() { - if (DEBUG) { - Log.d(TAG, "maybeUnblock() called."); - } - - if (isBlocked.get()) { - isBlocked.set(false); - playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); - return true; - } - - return false; - } - - /*////////////////////////////////////////////////////////////////////////// - // Metadata Synchronization - //////////////////////////////////////////////////////////////////////////*/ - - private void maybeSync(final boolean wasBlocked) { - if (DEBUG) { - Log.d(TAG, "maybeSync() called."); - } - - final PlayQueueItem currentItem = playQueue.getItem(); - if (isBlocked.get() || currentItem == null) { - return; - } - - playbackListener.onPlaybackSynchronize(currentItem, wasBlocked); - } - - private synchronized void maybeSynchronizePlayer() { - if (isPlayQueueReady() && isPlaybackReady()) { - final boolean isBlockReleased = maybeUnblock(); - maybeSync(isBlockReleased); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // MediaSource Loading - //////////////////////////////////////////////////////////////////////////*/ - - private Observable getEdgeIntervalSignal() { - return Observable.interval(progressUpdateIntervalMillis, - TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .filter(ignored -> - playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis)); - } - - private Disposable getDebouncedLoader() { - return debouncedSignal.mergeWith(nearEndIntervalSignal) - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .subscribeOn(Schedulers.single()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(timestamp -> loadImmediate()); - } - - private void loadDebounced() { - debouncedSignal.onNext(System.currentTimeMillis()); - } - - private void loadImmediate() { - if (DEBUG) { - Log.d(TAG, "MediaSource - loadImmediate() called"); - } - final ItemsToLoad itemsToLoad = getItemsToLoad(playQueue); - if (itemsToLoad == null) { - return; - } - - // Evict the previous items being loaded to free up memory, before start loading new ones - maybeClearLoaders(); - - maybeLoadItem(itemsToLoad.center); - for (final PlayQueueItem item : itemsToLoad.neighbors) { - maybeLoadItem(item); - } - } - - private void maybeLoadItem(@NonNull final PlayQueueItem item) { - if (DEBUG) { - Log.d(TAG, "maybeLoadItem() called."); - } - if (playQueue.indexOf(item) >= playlist.size()) { - return; - } - - if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { - if (DEBUG) { - Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + "] " - + "with url=[" + item.getUrl() + "]"); - } - - loadingItems.add(item); - final Disposable loader = getLoadedMediaSource(item) - .observeOn(AndroidSchedulers.mainThread()) - /* No exception handling since getLoadedMediaSource guarantees nonnull return */ - .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); - loaderReactor.add(loader); - } - } - - private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { - return stream.getStream() - .map(streamInfo -> Optional - .ofNullable(playbackListener.sourceOf(stream, streamInfo)) - .flatMap(source -> - MediaItemTag.from(source.getMediaItem()) - .map(tag -> { - final int serviceId = streamInfo.getServiceId(); - final long expiration = System.currentTimeMillis() - + getCacheExpirationMillis(serviceId); - return new LoadedMediaSource(source, tag, stream, - expiration); - }) - ) - .orElseGet(() -> { - final String message = "Unable to resolve source from stream info. " - + "URL: " + stream.getUrl() - + ", audio count: " + streamInfo.getAudioStreams().size() - + ", video count: " + streamInfo.getVideoOnlyStreams().size() - + ", " + streamInfo.getVideoStreams().size(); - return FailedMediaSource.of(stream, - new MediaSourceResolutionException(message)); - }) - ) - .onErrorReturn(throwable -> { - if (throwable instanceof ExtractionException) { - return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); - } - // Non-source related error expected here (e.g. network), - // should allow retry shortly after the error. - final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3, - TimeUnit.SECONDS); - return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn); - }); - } - - private void onMediaSourceReceived(@NonNull final PlayQueueItem item, - @NonNull final ManagedMediaSource mediaSource) { - if (DEBUG) { - Log.d(TAG, "MediaSource - Loaded=[" + item.getTitle() - + "] with url=[" + item.getUrl() + "]"); - } - - loadingItems.remove(item); - - final int itemIndex = playQueue.indexOf(item); - // Only update the playlist timeline for items at the current index or after. - if (isCorrectionNeeded(item)) { - if (DEBUG) { - Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " - + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); - } - playlist.update(itemIndex, mediaSource, removeMediaSourceHandler, - this::maybeSynchronizePlayer); - } - } - - /** - * Checks if the corresponding MediaSource in - * {@link com.google.android.exoplayer2.source.ConcatenatingMediaSource} - * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback - * readiness or playlist desynchronization. - *

- * If the given {@link PlayQueueItem} is currently being played and is already loaded, - * then correction is not only needed if the playlist is desynchronized. Otherwise, the - * check depends on the status (e.g. expiration or placeholder) of the - * {@link ManagedMediaSource}. - *

- * - * @param item {@link PlayQueueItem} to check - * @return whether a correction is needed - */ - private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { - final int index = playQueue.indexOf(item); - final ManagedMediaSource mediaSource = playlist.get(index); - return mediaSource != null && mediaSource.shouldBeReplacedWith(item, - index != playQueue.getIndex()); - } - - /** - * Checks if the current playing index contains an expired {@link ManagedMediaSource}. - * If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and - * {@link #loadImmediate()} is called to reload the current item. - *

- * If not, then the media source at the current index is ready for playback, and - * {@link #maybeSynchronizePlayer()} is called. - *

- * Under both cases, {@link #maybeSync(boolean)} will be called to ensure the listener - * is up-to-date. - */ - private void maybeRenewCurrentIndex() { - final int currentIndex = playQueue.getIndex(); - final PlayQueueItem currentItem = playQueue.getItem(); - final ManagedMediaSource currentSource = playlist.get(currentIndex); - if (currentItem == null || currentSource == null) { - return; - } - - if (!currentSource.shouldBeReplacedWith(currentItem, true)) { - maybeSynchronizePlayer(); - return; - } - - if (DEBUG) { - Log.d(TAG, "MediaSource - Reloading currently playing, " - + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); - } - playlist.invalidate(currentIndex, removeMediaSourceHandler, this::loadImmediate); - } - - private void maybeClearLoaders() { - if (DEBUG) { - Log.d(TAG, "MediaSource - maybeClearLoaders() called."); - } - if (!loadingItems.contains(playQueue.getItem()) - && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { - loaderReactor.clear(); - loadingItems.clear(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // MediaSource Playlist Helpers - //////////////////////////////////////////////////////////////////////////*/ - - private void resetSources() { - if (DEBUG) { - Log.d(TAG, "resetSources() called."); - } - playlist = new ManagedMediaSourcePlaylist(); - } - - private void populateSources() { - if (DEBUG) { - Log.d(TAG, "populateSources() called."); - } - while (playlist.size() < playQueue.size()) { - playlist.expand(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Manager Helpers - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - private static ItemsToLoad getItemsToLoad(@NonNull final PlayQueue playQueue) { - // The current item has higher priority - final int currentIndex = playQueue.getIndex(); - final PlayQueueItem currentItem = playQueue.getItem(currentIndex); - if (currentItem == null) { - return null; - } - - // The rest are just for seamless playback - // Although timeline is not updated prior to the current index, these sources are still - // loaded into the cache for faster retrieval at a potentially later time. - final int leftBound = Math.max(0, currentIndex - MediaSourceManager.WINDOW_SIZE); - final int rightLimit = currentIndex + MediaSourceManager.WINDOW_SIZE + 1; - final int rightBound = Math.min(playQueue.size(), rightLimit); - final Set neighbors = new ArraySet<>( - playQueue.getStreams().subList(leftBound, rightBound)); - - // Do a round robin - final int excess = rightLimit - playQueue.size(); - if (excess >= 0) { - neighbors.addAll(playQueue.getStreams() - .subList(0, Math.min(playQueue.size(), excess))); - } - neighbors.remove(currentItem); - - return new ItemsToLoad(currentItem, neighbors); - } - - private static class ItemsToLoad { - @NonNull - private final PlayQueueItem center; - @NonNull - private final Collection neighbors; - - ItemsToLoad(@NonNull final PlayQueueItem center, - @NonNull final Collection neighbors) { - this.center = center; - this.neighbors = neighbors; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java deleted file mode 100644 index 737607001..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.source.MediaSource; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -public interface PlaybackListener { - /** - * Called to check if the currently playing stream is approaching the end of its playback. - * Implementation should return true when the current playback position is progressing within - * timeToEndMillis or less to its playback during. - *

- * May be called at any time. - *

- * - * @param timeToEndMillis - * @return whether the stream is approaching end of playback - */ - boolean isApproachingPlaybackEdge(long timeToEndMillis); - - /** - * Called when the stream at the current queue index is not ready yet. - * Signals to the listener to block the player from playing anything and notify the source - * is now invalid. - *

- * May be called at any time. - *

- */ - void onPlaybackBlock(); - - /** - * Called when the stream at the current queue index is ready. - * Signals to the listener to resume the player by preparing a new source. - *

- * May be called only when the player is blocked. - *

- * - * @param mediaSource - */ - void onPlaybackUnblock(MediaSource mediaSource); - - /** - * Called when the queue index is refreshed. - * Signals to the listener to synchronize the player's window to the manager's - * window. - *

- * May be called anytime at any amount once unblock is called. - *

- * - * @param item item the player should be playing/synchronized to - * @param wasBlocked was the player recently released from blocking state - */ - void onPlaybackSynchronize(@NonNull PlayQueueItem item, boolean wasBlocked); - - /** - * Requests the listener to resolve a stream info into a media source - * according to the listener's implementation (background, popup or main video player). - *

- * May be called at any time. - *

- * @param item - * @param info - * @return the corresponding {@link MediaSource} - */ - @Nullable - MediaSource sourceOf(PlayQueueItem item, StreamInfo info); - - /** - * Called when the play queue can no longer be played or used. - * Currently, this means the play queue is empty and complete. - * Signals to the listener that it should shutdown. - *

- * May be called at any time. - *

- */ - void onPlaybackShutdown(); - - /** - * Called whenever the play queue was edited (items were added, deleted or moved), - * use this to e.g. update notification buttons or fragment ui. - *

- * May be called at any time. - *

- */ - void onPlayQueueEdited(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java deleted file mode 100644 index da6cb36d4..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.content.Context; -import android.view.SurfaceHolder; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.video.PlaceholderSurface; - -/** - * Prevent error message: 'Unrecoverable player error occurred' - * In case of rotation some users see this kind of an error which is preventable - * having a Callback that handles the lifecycle of the surface. - *

- * How?: In case we are no longer able to write to the surface eg. through rotation/putting in - * background we set set a DummySurface. Although it it works on API >= 23 only. - * Result: we get a little video interruption (audio is still fine) but we won't get the - * 'Unrecoverable player error occurred' error message. - *

- * This implementation is based on: - * 'ExoPlayer stuck in buffering after re-adding the surface view a few time #2703' - *

- * -> exoplayer fix suggestion link - * https://github.com/google/ExoPlayer/issues/2703#issuecomment-300599981 - */ -public final class SurfaceHolderCallback implements SurfaceHolder.Callback { - - private final Context context; - private final Player player; - private PlaceholderSurface placeholderSurface; - - public SurfaceHolderCallback(final Context context, final Player player) { - this.context = context; - this.player = player; - } - - @Override - public void surfaceCreated(final SurfaceHolder holder) { - player.setVideoSurface(holder.getSurface()); - } - - @Override - public void surfaceChanged(final SurfaceHolder holder, - final int format, - final int width, - final int height) { - } - - @Override - public void surfaceDestroyed(final SurfaceHolder holder) { - if (placeholderSurface == null) { - placeholderSurface = PlaceholderSurface.newInstanceV17(context, false); - } - player.setVideoSurface(placeholderSurface); - } - - public void release() { - if (placeholderSurface != null) { - placeholderSurface.release(); - placeholderSurface = null; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java deleted file mode 100644 index 02bb6b5ba..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -import java.util.List; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.core.SingleObserver; -import io.reactivex.rxjava3.disposables.Disposable; - -abstract class AbstractInfoPlayQueue> - extends PlayQueue { - boolean isInitial; - private boolean isComplete; - - final int serviceId; - final String baseUrl; - @Nullable - Page nextPage; - - private transient Disposable fetchReactor; - - protected AbstractInfoPlayQueue(final T info) { - this(info, 0); - } - - protected AbstractInfoPlayQueue(final T info, final int index) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), - info.getRelatedItems() - .stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()), - index); - } - - protected AbstractInfoPlayQueue(final int serviceId, - final String url, - final Page nextPage, - final List streams, - final int index) { - super(index, extractListItems(streams)); - - this.baseUrl = url; - this.nextPage = nextPage; - this.serviceId = serviceId; - - this.isInitial = streams.isEmpty(); - this.isComplete = !isInitial && !Page.isValid(nextPage); - } - - protected abstract String getTag(); - - @Override - public boolean isComplete() { - return isComplete; - } - - SingleObserver getHeadListObserver() { - return new SingleObserver<>() { - @Override - public void onSubscribe(@NonNull final Disposable d) { - if (isComplete || !isInitial || (fetchReactor != null - && !fetchReactor.isDisposed())) { - d.dispose(); - } else { - fetchReactor = d; - } - } - - @Override - public void onSuccess(@NonNull final T result) { - isInitial = false; - if (!result.hasNextPage()) { - isComplete = true; - } - nextPage = result.getNextPage(); - - append(extractListItems(result.getRelatedItems() - .stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()))); - - fetchReactor.dispose(); - fetchReactor = null; - } - - @Override - public void onError(@NonNull final Throwable e) { - Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); - isComplete = true; - notifyChange(); - } - }; - } - - SingleObserver> getNextPageObserver() { - return new SingleObserver<>() { - @Override - public void onSubscribe(@NonNull final Disposable d) { - if (isComplete || isInitial || (fetchReactor != null - && !fetchReactor.isDisposed())) { - d.dispose(); - } else { - fetchReactor = d; - } - } - - @Override - public void onSuccess( - @NonNull final ListExtractor.InfoItemsPage result) { - if (!result.hasNextPage()) { - isComplete = true; - } - nextPage = result.getNextPage(); - - append(extractListItems(result.getItems() - .stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()))); - - fetchReactor.dispose(); - fetchReactor = null; - } - - @Override - public void onError(@NonNull final Throwable e) { - Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); - isComplete = true; - notifyChange(); - } - }; - } - - @Override - public void dispose() { - super.dispose(); - if (fetchReactor != null) { - fetchReactor.dispose(); - } - fetchReactor = null; - } - - private static List extractListItems(final List infoItems) { - return infoItems.stream().map(PlayQueueItem::new).collect(Collectors.toList()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java deleted file mode 100644 index a9eb2a19c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - - -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.Collections; -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { - - final ListLinkHandler linkHandler; - - public ChannelTabPlayQueue(final int serviceId, - final ListLinkHandler linkHandler, - final Page nextPage, - final List streams, - final int index) { - super(serviceId, linkHandler.getUrl(), nextPage, streams, index); - this.linkHandler = linkHandler; - } - - public ChannelTabPlayQueue(final int serviceId, - final ListLinkHandler linkHandler) { - this(serviceId, linkHandler, null, Collections.emptyList(), 0); - } - - @Override - protected String getTag() { - return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); - } - - @Override - public void fetch() { - if (isInitial) { - ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); - } else { - ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getNextPageObserver()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java deleted file mode 100644 index 8a66dc2ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ /dev/null @@ -1,575 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.BackpressureStrategy; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.subjects.PublishSubject; - -/** - * PlayQueue is responsible for keeping track of a list of streams and the index of - * the stream that should be currently playing. - *

- * This class contains basic manipulation of a playlist while also functions as a - * message bus, providing all listeners with new updates to the play queue. - *

- *

- * This class can be serialized for passing intents, but in order to start the - * message bus, it must be initialized. - *

- */ -public abstract class PlayQueue implements Serializable { - public static final boolean DEBUG = MainActivity.DEBUG; - @NonNull - private final AtomicInteger queueIndex; - private final List history = new ArrayList<>(); - - private List backup; - private List streams; - - private transient PublishSubject eventBroadcast; - private transient Flowable broadcastReceiver; - private transient boolean disposed = false; - - PlayQueue(final int index, final List startWith) { - streams = new ArrayList<>(startWith); - - if (streams.size() > index) { - history.add(streams.get(index)); - } - - queueIndex = new AtomicInteger(index); - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist actions - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Initializes the play queue message buses. - *

- * Also starts a self reporter for logging if debug mode is enabled. - *

- */ - public void init() { - eventBroadcast = PublishSubject.create(); - - broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) - .observeOn(AndroidSchedulers.mainThread()) - .startWithItem(new InitEvent()); - } - - /** - * Dispose the play queue by stopping all message buses. - */ - public void dispose() { - if (eventBroadcast != null) { - eventBroadcast.onComplete(); - } - - eventBroadcast = null; - broadcastReceiver = null; - disposed = true; - } - - /** - * Checks if the queue is complete. - *

- * A queue is complete if it has loaded all items in an external playlist - * single stream or local queues are always complete. - *

- * - * @return whether the queue is complete - */ - public abstract boolean isComplete(); - - /** - * Load partial queue in the background, does nothing if the queue is complete. - */ - public abstract void fetch(); - - /*////////////////////////////////////////////////////////////////////////// - // Readonly ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * @return the current index that should be played - */ - public int getIndex() { - return queueIndex.get(); - } - - /** - * Changes the current playing index to a new index. - *

- * This method is guarded using in a circular manner for index exceeding the play queue size. - *

- *

- * Will emit a {@link SelectEvent} if the index is not the current playing index. - *

- * - * @param index the index to be set - */ - public synchronized void setIndex(final int index) { - final int oldIndex = getIndex(); - - final int newIndex; - - if (index < 0) { - newIndex = 0; - } else if (index < streams.size()) { - // Regular assignment for index in bounds - newIndex = index; - } else if (streams.isEmpty()) { - // Out of bounds from here on - // Need to check if stream is empty to prevent arithmetic error and negative index - newIndex = 0; - } else if (isComplete()) { - // Circular indexing - newIndex = index % streams.size(); - } else { - // Index of last element - newIndex = streams.size() - 1; - } - - queueIndex.set(newIndex); - - if (oldIndex != newIndex) { - history.add(streams.get(newIndex)); - } - - /* - TODO: Documentation states that a SelectEvent will only be emitted if the new index is... - different from the old one but this is emitted regardless? Not sure what this what it does - exactly so I won't touch it - */ - broadcast(new SelectEvent(oldIndex, newIndex)); - } - - /** - * @return the current item that should be played, or null if the queue is empty - */ - @Nullable - public PlayQueueItem getItem() { - return getItem(getIndex()); - } - - /** - * @param index the index of the item to return - * @return the item at the given index, or null if the index is out of bounds - */ - @Nullable - public PlayQueueItem getItem(final int index) { - if (index < 0 || index >= streams.size()) { - return null; - } - return streams.get(index); - } - - /** - * Returns the index of the given item using referential equality. - * May be null despite play queue contains identical item. - * - * @param item the item to find the index of - * @return the index of the given item - */ - public int indexOf(@NonNull final PlayQueueItem item) { - return streams.indexOf(item); - } - - /** - * @return the current size of play queue. - */ - public int size() { - return streams.size(); - } - - /** - * Checks if the play queue is empty. - * - * @return whether the play queue is empty - */ - public boolean isEmpty() { - return streams.isEmpty(); - } - - /** - * Determines if the current play queue is shuffled. - * - * @return whether the play queue is shuffled - */ - public boolean isShuffled() { - return backup != null; - } - - /** - * @return an immutable view of the play queue - */ - @NonNull - public List getStreams() { - return Collections.unmodifiableList(streams); - } - - /*////////////////////////////////////////////////////////////////////////// - // Write ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Returns the play queue's update broadcast. - * May be null if the play queue message bus is not initialized. - * - * @return the play queue's update broadcast - */ - @Nullable - public Flowable getBroadcastReceiver() { - return broadcastReceiver; - } - - /** - * Changes the current playing index by an offset amount. - *

- * Will emit a {@link SelectEvent} if offset is non-zero. - *

- * - * @param offset the offset relative to the current index - */ - public synchronized void offsetIndex(final int offset) { - setIndex(getIndex() + offset); - } - - /** - * Notifies that a change has occurred. - */ - public synchronized void notifyChange() { - broadcast(new AppendEvent(0)); - } - - /** - * Appends the given {@link PlayQueueItem}s to the current play queue. - *

- * If the play queue is shuffled, then append the items to the backup queue as is and - * append the shuffle items to the play queue. - *

- *

- * Will emit a {@link AppendEvent} on any given context. - *

- * - * @param items {@link PlayQueueItem}s to append - */ - public synchronized void append(@NonNull final List items) { - final List itemList = new ArrayList<>(items); - - if (isShuffled()) { - backup.addAll(itemList); - Collections.shuffle(itemList); - } - if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued() - && !itemList.get(0).isAutoQueued()) { - streams.remove(streams.size() - 1); - } - streams.addAll(itemList); - - broadcast(new AppendEvent(itemList.size())); - } - - /** - * Add the given item after the current stream. - * - * @param item item to add. - * @param skipIfSame if set, skip adding if the next stream is the same stream. - */ - public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) { - final int currentIndex = getIndex(); - // if the next item is the same item as the one we want to enqueue, skip if flag is true - if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) { - return; - } - append(List.of(item)); - move(size() - 1, currentIndex + 1); - } - - /** - * Removes the item at the given index from the play queue. - *

- * The current playing index will decrement if it is greater than the index being removed. - * On cases where the current playing index exceeds the playlist range, it is set to 0. - *

- *

- * Will emit a {@link RemoveEvent} if the index is within the play queue index range. - *

- * - * @param index the index of the item to remove - */ - public synchronized void remove(final int index) { - if (index >= streams.size() || index < 0) { - return; - } - removeInternal(index); - broadcast(new RemoveEvent(index, getIndex())); - } - - /** - * Report an exception for the item at the current index in order and skip to the next one - *

- * This is done as a separate event as the underlying manager may have - * different implementation regarding exceptions. - *

- */ - public synchronized void error() { - final int oldIndex = getIndex(); - queueIndex.incrementAndGet(); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - broadcast(new ErrorEvent(oldIndex, getIndex())); - } - - private synchronized void removeInternal(final int removeIndex) { - final int currentIndex = queueIndex.get(); - final int size = size(); - - if (currentIndex > removeIndex) { - queueIndex.decrementAndGet(); - - } else if (currentIndex >= size) { - queueIndex.set(currentIndex % (size - 1)); - - } else if (currentIndex == removeIndex && currentIndex == size - 1) { - queueIndex.set(0); - } - - if (backup != null) { - backup.remove(getItem(removeIndex)); - } - - history.remove(streams.remove(removeIndex)); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - } - - /** - * Moves a queue item at the source index to the target index. - *

- * If the item being moved is the currently playing, then the current playing index is set - * to that of the target. - * If the moved item is not the currently playing and moves to an index AFTER the - * current playing index, then the current playing index is decremented. - * Vice versa if the an item after the currently playing is moved BEFORE. - *

- * - * @param source the original index of the item - * @param target the new index of the item - */ - public synchronized void move(final int source, final int target) { - if (source < 0 || target < 0) { - return; - } - if (source >= streams.size() || target >= streams.size()) { - return; - } - - final int current = getIndex(); - if (source == current) { - queueIndex.set(target); - } else if (source < current && target >= current) { - queueIndex.decrementAndGet(); - } else if (source > current && target <= current) { - queueIndex.incrementAndGet(); - } - - final PlayQueueItem playQueueItem = streams.remove(source); - playQueueItem.setAutoQueued(false); - streams.add(target, playQueueItem); - broadcast(new MoveEvent(source, target)); - } - - /** - * Sets the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - * @param position the recovery position - */ - public synchronized void setRecovery(final int index, final long position) { - if (index < 0 || index >= streams.size()) { - return; - } - - streams.get(index).setRecoveryPosition(position); - broadcast(new RecoveryEvent(index, position)); - } - - /** - * Revoke the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - */ - public synchronized void unsetRecovery(final int index) { - setRecovery(index, PlayQueueItem.RECOVERY_UNSET); - } - - /** - * Shuffles the current play queue - *

- * This method first backs up the existing play queue and item being played. Then a newly - * shuffled play queue will be generated along with currently playing item placed at the - * beginning of the queue. This item will also be added to the history. - *

- *

- * Will emit a {@link ReorderEvent} if shuffled. - *

- * - * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on - * top, so shuffling a size-2 list does nothing) - */ - public synchronized void shuffle() { - // Create a backup if it doesn't already exist - // Note: The backup-list has to be created at all cost (even when size <= 2). - // Otherwise it's not possible to enter shuffle-mode! - if (backup == null) { - backup = new ArrayList<>(streams); - } - // Can't shuffle a list that's empty or only has one element - if (size() <= 2) { - return; - } - - final int originalIndex = getIndex(); - final PlayQueueItem currentItem = getItem(); - - Collections.shuffle(streams); - - // Move currentItem to the head of the queue - streams.remove(currentItem); - streams.add(0, currentItem); - queueIndex.set(0); - - history.add(currentItem); - - broadcast(new ReorderEvent(originalIndex, 0)); - } - - /** - * Unshuffles the current play queue if a backup play queue exists. - *

- * This method undoes shuffling and index will be set to the previously playing item if found, - * otherwise, the index will reset to 0. - *

- *

- * Will emit a {@link ReorderEvent} if a backup exists. - *

- */ - public synchronized void unshuffle() { - if (backup == null) { - return; - } - final int originIndex = getIndex(); - final PlayQueueItem current = getItem(); - - streams = backup; - backup = null; - - final int newIndex = streams.indexOf(current); - if (newIndex != -1) { - queueIndex.set(newIndex); - } else { - queueIndex.set(0); - } - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - - broadcast(new ReorderEvent(originIndex, queueIndex.get())); - } - - /** - * Selects previous played item. - * - * This method removes currently playing item from history and - * starts playing the last item from history if it exists - * - * @return true if history is not empty and the item can be played - * */ - public synchronized boolean previous() { - if (history.size() <= 1) { - return false; - } - - history.remove(history.size() - 1); - - final PlayQueueItem last = history.remove(history.size() - 1); - setIndex(indexOf(last)); - - return true; - } - - /* - * Compares two PlayQueues. Useful when a user switches players but queue is the same so - * we don't have to do anything with new queue. - * This method also gives a chance to track history of items in a queue in - * VideoDetailFragment without duplicating items from two identical queues - */ - public boolean equalStreams(@Nullable final PlayQueue other) { - if (other == null) { - return false; - } - if (size() != other.size()) { - return false; - } - for (int i = 0; i < size(); i++) { - final PlayQueueItem stream = streams.get(i); - final PlayQueueItem otherStream = other.streams.get(i); - // Check is based on serviceId and URL - if (!stream.isSameItem(otherStream)) { - return false; - } - } - return true; - } - - public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { - if (equalStreams(other)) { - //noinspection ConstantConditions - return other.getIndex() == getIndex(); //NOSONAR: other is not null - } - return false; - } - - public boolean isDisposed() { - return disposed; - } - /*////////////////////////////////////////////////////////////////////////// - // Rx Broadcast - //////////////////////////////////////////////////////////////////////////*/ - - private void broadcast(@NonNull final PlayQueueEvent event) { - if (eventBroadcast != null) { - eventBroadcast.onNext(event); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java deleted file mode 100644 index b647e801d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java +++ /dev/null @@ -1,208 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2016-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.player.playqueue; - -import android.content.Context; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; -import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent; -import org.schabi.newpipe.util.FallbackViewHolder; - -import java.util.List; - -import io.reactivex.rxjava3.core.Observer; -import io.reactivex.rxjava3.disposables.Disposable; - -public class PlayQueueAdapter extends RecyclerView.Adapter { - private static final String TAG = PlayQueueAdapter.class.toString(); - - private static final int ITEM_VIEW_TYPE_ID = 0; - private static final int FOOTER_VIEW_TYPE_ID = 1; - - private final PlayQueueItemBuilder playQueueItemBuilder; - private final PlayQueue playQueue; - private boolean showFooter = false; - private View footer = null; - - private Disposable playQueueReactor; - - public PlayQueueAdapter(final Context context, final PlayQueue playQueue) { - if (playQueue.getBroadcastReceiver() == null) { - throw new IllegalStateException("Play Queue has not been initialized."); - } - - this.playQueueItemBuilder = new PlayQueueItemBuilder(context); - this.playQueue = playQueue; - - playQueue.getBroadcastReceiver().toObservable().subscribe(getReactor()); - } - - private Observer getReactor() { - return new Observer() { - @Override - public void onSubscribe(@NonNull final Disposable d) { - if (playQueueReactor != null) { - playQueueReactor.dispose(); - } - playQueueReactor = d; - } - - @Override - public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { - if (playQueueReactor != null) { - onPlayQueueChanged(playQueueMessage); - } - } - - @Override - public void onError(@NonNull final Throwable e) { } - - @Override - public void onComplete() { - dispose(); - } - }; - - } - - private void onPlayQueueChanged(final PlayQueueEvent message) { - switch (message.type()) { - case RECOVERY: - // Do nothing. - break; - case SELECT: - final SelectEvent selectEvent = (SelectEvent) message; - notifyItemChanged(selectEvent.getOldIndex()); - notifyItemChanged(selectEvent.getNewIndex()); - break; - case APPEND: - final AppendEvent appendEvent = (AppendEvent) message; - notifyItemRangeInserted(playQueue.size(), appendEvent.getAmount()); - break; - case ERROR: - final ErrorEvent errorEvent = (ErrorEvent) message; - notifyItemChanged(errorEvent.getErrorIndex()); - notifyItemChanged(errorEvent.getQueueIndex()); - break; - case REMOVE: - final RemoveEvent removeEvent = (RemoveEvent) message; - notifyItemRemoved(removeEvent.getRemoveIndex()); - notifyItemChanged(removeEvent.getQueueIndex()); - break; - case MOVE: - final MoveEvent moveEvent = (MoveEvent) message; - notifyItemMoved(moveEvent.getFromIndex(), moveEvent.getToIndex()); - break; - case INIT: - case REORDER: - default: - notifyDataSetChanged(); - break; - } - } - - public void dispose() { - if (playQueueReactor != null) { - playQueueReactor.dispose(); - } - playQueueReactor = null; - } - - public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) { - playQueueItemBuilder.setOnSelectedListener(listener); - } - - public void unsetSelectedListener() { - playQueueItemBuilder.setOnSelectedListener(null); - } - - public void setFooter(final View footer) { - this.footer = footer; - notifyItemChanged(playQueue.size()); - } - - public void showFooter(final boolean show) { - showFooter = show; - notifyItemChanged(playQueue.size()); - } - - public List getItems() { - return playQueue.getStreams(); - } - - @Override - public int getItemCount() { - int count = playQueue.getStreams().size(); - if (footer != null && showFooter) { - count++; - } - return count; - } - - @Override - public int getItemViewType(final int position) { - if (footer != null && position == playQueue.getStreams().size() && showFooter) { - return FOOTER_VIEW_TYPE_ID; - } - - return ITEM_VIEW_TYPE_ID; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int type) { - switch (type) { - case FOOTER_VIEW_TYPE_ID: - return new HFHolder(footer); - case ITEM_VIEW_TYPE_ID: - return new PlayQueueItemHolder(LayoutInflater.from(parent.getContext()) - .inflate(R.layout.play_queue_item, parent, false)); - default: - Log.e(TAG, "Attempting to create view holder with undefined type: " + type); - return new FallbackViewHolder(new View(parent.getContext())); - } - } - - @Override - public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, - final int position) { - if (holder instanceof PlayQueueItemHolder) { - final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; - - // Build the list item - playQueueItemBuilder - .buildStreamInfoItem(itemHolder, playQueue.getStreams().get(position)); - - // Check if the current item should be selected/highlighted - final boolean isSelected = playQueue.getIndex() == position; - itemHolder.itemView.setSelected(isSelected); - } else if (holder instanceof HFHolder && position == playQueue.getStreams().size() - && footer != null && showFooter) { - ((HFHolder) holder).view = footer; - } - } - - public static class HFHolder extends RecyclerView.ViewHolder { - public View view; - - public HFHolder(final View v) { - super(v); - view = v; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt deleted file mode 100644 index f1952ef95..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.player.playqueue - -import java.io.Serializable - -sealed interface PlayQueueEvent : Serializable { - fun type(): Type - - class InitEvent : PlayQueueEvent { - override fun type() = Type.INIT - } - - // sent when the index is changed - class SelectEvent(val oldIndex: Int, val newIndex: Int) : PlayQueueEvent { - override fun type() = Type.SELECT - } - - // sent when more streams are added to the play queue - class AppendEvent(val amount: Int) : PlayQueueEvent { - override fun type() = Type.APPEND - } - - // sent when a pending stream is removed from the play queue - class RemoveEvent(val removeIndex: Int, val queueIndex: Int) : PlayQueueEvent { - override fun type() = Type.REMOVE - } - - // sent when two streams swap place in the play queue - class MoveEvent(val fromIndex: Int, val toIndex: Int) : PlayQueueEvent { - override fun type() = Type.MOVE - } - - // sent when queue is shuffled - class ReorderEvent(val fromSelectedIndex: Int, val toSelectedIndex: Int) : PlayQueueEvent { - override fun type() = Type.REORDER - } - - // sent when recovery record is set on a stream - class RecoveryEvent(val index: Int, val position: Long) : PlayQueueEvent { - override fun type() = Type.RECOVERY - } - - // sent when the item at index has caused an exception - class ErrorEvent(val errorIndex: Int, val queueIndex: Int) : PlayQueueEvent { - override fun type() = Type.ERROR - } - - // It is necessary only for use in java code. Remove it and use kotlin pattern - // matching when all users of this enum are converted to kotlin - enum class Type { INIT, SELECT, APPEND, REMOVE, MOVE, REORDER, RECOVERY, ERROR } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java deleted file mode 100644 index d1d897c39..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.Serializable; -import java.util.List; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class PlayQueueItem implements Serializable { - public static final long RECOVERY_UNSET = Long.MIN_VALUE; - private static final String EMPTY_STRING = ""; - - @NonNull - private final String title; - @NonNull - private final String url; - private final int serviceId; - private final long duration; - @NonNull - private final List thumbnails; - @NonNull - private final String uploader; - private final String uploaderUrl; - @NonNull - private final StreamType streamType; - - private boolean isAutoQueued; - - private long recoveryPosition; - private Throwable error; - - public PlayQueueItem(@NonNull final StreamInfo info) { - this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), - info.getThumbnails(), info.getUploaderName(), - info.getUploaderUrl(), info.getStreamType()); - - if (info.getStartPosition() > 0) { - setRecoveryPosition(info.getStartPosition() * 1000); - } - } - - PlayQueueItem(@NonNull final StreamInfoItem item) { - this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), - item.getThumbnails(), item.getUploaderName(), - item.getUploaderUrl(), item.getStreamType()); - } - - @SuppressWarnings("ParameterNumber") - private PlayQueueItem(@Nullable final String name, @Nullable final String url, - final int serviceId, final long duration, - final List thumbnails, @Nullable final String uploader, - final String uploaderUrl, @NonNull final StreamType streamType) { - this.title = name != null ? name : EMPTY_STRING; - this.url = url != null ? url : EMPTY_STRING; - this.serviceId = serviceId; - this.duration = duration; - this.thumbnails = thumbnails; - this.uploader = uploader != null ? uploader : EMPTY_STRING; - this.uploaderUrl = uploaderUrl; - this.streamType = streamType; - - this.recoveryPosition = RECOVERY_UNSET; - } - - /** Whether these two items should be treated as the same stream - * for the sake of keeping the same player running when e.g. jumping between timestamps. - * - * @param other the {@link PlayQueueItem} to compare against. - * @return whether the two items are the same so the stream can be re-used. - */ - public boolean isSameItem(@Nullable final PlayQueueItem other) { - if (other == null) { - return false; - } - // We assume that the same service & URL uniquely determines - // that we can keep the same stream running. - return serviceId == other.serviceId - && url.equals(other.url); - } - - @NonNull - public String getTitle() { - return title; - } - - @NonNull - public String getUrl() { - return url; - } - - public int getServiceId() { - return serviceId; - } - - public long getDuration() { - return duration; - } - - @NonNull - public List getThumbnails() { - return thumbnails; - } - - @NonNull - public String getUploader() { - return uploader; - } - - public String getUploaderUrl() { - return uploaderUrl; - } - - @NonNull - public StreamType getStreamType() { - return streamType; - } - - public long getRecoveryPosition() { - return recoveryPosition; - } - - /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { - this.recoveryPosition = recoveryPosition; - } - - @Nullable - public Throwable getError() { - return error; - } - - @NonNull - public Single getStream() { - return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) - .subscribeOn(Schedulers.io()) - .doOnError(throwable -> error = throwable); - } - - public boolean isAutoQueued() { - return isAutoQueued; - } - - //////////////////////////////////////////////////////////////////////////// - // Item States, keep external access out - //////////////////////////////////////////////////////////////////////////// - - public void setAutoQueued(final boolean autoQueued) { - isAutoQueued = autoQueued; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java deleted file mode 100644 index 8994aef79..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import android.content.Context; -import android.text.TextUtils; -import android.view.MotionEvent; -import android.view.View; - -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.image.CoilHelper; - -public class PlayQueueItemBuilder { - private static final String TAG = PlayQueueItemBuilder.class.toString(); - private OnSelectedListener onItemClickListener; - - public PlayQueueItemBuilder(final Context context) { - } - - public void setOnSelectedListener(final OnSelectedListener listener) { - this.onItemClickListener = listener; - } - - public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueueItem item) { - if (!TextUtils.isEmpty(item.getTitle())) { - holder.itemVideoTitleView.setText(item.getTitle()); - } - holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), - ServiceHelper.getNameOfServiceById(item.getServiceId()))); - - if (item.getDuration() > 0) { - holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); - } else { - holder.itemDurationView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadThumbnail(holder.itemThumbnailView, item.getThumbnails()); - - holder.itemRoot.setOnClickListener(view -> { - if (onItemClickListener != null) { - onItemClickListener.selected(item, view); - } - }); - - holder.itemRoot.setOnLongClickListener(view -> { - if (onItemClickListener != null) { - onItemClickListener.held(item, view); - return true; - } - return false; - }); - - holder.itemHandle.setOnTouchListener(getOnTouchListener(holder)); - } - - private View.OnTouchListener getOnTouchListener(final PlayQueueItemHolder holder) { - return (view, motionEvent) -> { - view.performClick(); - if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN - && onItemClickListener != null) { - onItemClickListener.onStartDrag(holder); - } - return false; - }; - } - - public interface OnSelectedListener { - void selected(PlayQueueItem item, View view); - - void held(PlayQueueItem item, View view); - - void onStartDrag(PlayQueueItemHolder viewHolder); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java deleted file mode 100644 index 23fc4bf18..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2016-2021 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.player.playqueue; - -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; - -public class PlayQueueItemHolder extends RecyclerView.ViewHolder { - public final TextView itemVideoTitleView; - public final TextView itemDurationView; - final TextView itemAdditionalDetailsView; - - public final ImageView itemThumbnailView; - final ImageView itemHandle; - - public final View itemRoot; - - PlayQueueItemHolder(final View v) { - super(v); - itemRoot = v.findViewById(R.id.itemRoot); - itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); - itemDurationView = v.findViewById(R.id.itemDurationView); - itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); - itemThumbnailView = v.findViewById(R.id.itemThumbnailView); - itemHandle = v.findViewById(R.id.itemHandle); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java deleted file mode 100644 index 6e2792d4f..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.core.math.MathUtils; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleCallback { - private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; - private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; - - public PlayQueueItemTouchCallback() { - super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT); - } - - public abstract void onMove(int sourceIndex, int targetIndex); - - public abstract void onSwiped(int index); - - @Override - public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, - final int viewSize, - final int viewSizeOutOfBounds, - final int totalSize, - final long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, - viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int clampedAbsVelocity = MathUtils.clamp(Math.abs(standardSpeed), - MINIMUM_INITIAL_DRAG_VELOCITY, MAXIMUM_INITIAL_DRAG_VELOCITY); - return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - final RecyclerView.ViewHolder source, - final RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - final int sourceIndex = source.getLayoutPosition(); - final int targetIndex = target.getLayoutPosition(); - onMove(sourceIndex, targetIndex); - return true; - } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return true; - } - - @Override - public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { - onSwiped(viewHolder.getBindingAdapterPosition()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java deleted file mode 100644 index 32316f393..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class PlaylistPlayQueue extends AbstractInfoPlayQueue { - - public PlaylistPlayQueue(final PlaylistInfo info) { - super(info); - } - - public PlaylistPlayQueue(final PlaylistInfo info, final int index) { - super(info, index); - } - - public PlaylistPlayQueue(final int serviceId, - final String url, - final Page nextPage, - final List streams, - final int index) { - super(serviceId, url, nextPage, streams, index); - } - - @Override - protected String getTag() { - return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()); - } - - @Override - public void fetch() { - if (this.isInitial) { - ExtractorHelper.getPlaylistInfo(this.serviceId, this.baseUrl, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); - } else { - ExtractorHelper.getMorePlaylistItems(this.serviceId, this.baseUrl, this.nextPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getNextPageObserver()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java deleted file mode 100644 index a072369d6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -import java.util.List; -import java.util.stream.Collectors; - -public final class SinglePlayQueue extends PlayQueue { - public SinglePlayQueue(final StreamInfoItem item) { - super(0, List.of(new PlayQueueItem(item))); - } - - public SinglePlayQueue(final StreamInfo info) { - super(0, List.of(new PlayQueueItem(info))); - } - public SinglePlayQueue(final PlayQueueItem item) { - super(0, List.of(item)); - } - public SinglePlayQueue(final StreamInfo info, final long startPosition) { - super(0, List.of(new PlayQueueItem(info))); - getItem().setRecoveryPosition(startPosition); - } - - public SinglePlayQueue(@NonNull final List items, final int index) { - super(index, playQueueItemsOf(items)); - } - - private static List playQueueItemsOf(@NonNull final List items) { - return items.stream().map(PlayQueueItem::new).collect(Collectors.toList()); - } - - @Override - public boolean isComplete() { - return true; - } - - @Override - public void fetch() { - // Item was already passed in constructor. - // No further items need to be fetched as this is a PlayQueue with only one item - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java deleted file mode 100644 index 2d4404b2a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; -import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.source.MediaSource; - -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.mediaitem.StreamInfoTag; -import org.schabi.newpipe.util.ListHelper; - -import java.util.List; - -public class AudioPlaybackResolver implements PlaybackResolver { - private static final String TAG = AudioPlaybackResolver.class.getSimpleName(); - - @NonNull - private final Context context; - @NonNull - private final PlayerDataSource dataSource; - @Nullable - private String audioTrack; - - public AudioPlaybackResolver(@NonNull final Context context, - @NonNull final PlayerDataSource dataSource) { - this.context = context; - this.dataSource = dataSource; - } - - /** - * Get a media source providing audio. If a service has no separate {@link AudioStream}s we - * use a video stream as audio source to support audio background playback. - * - * @param info of the stream - * @return the audio source to use or null if none could be found - */ - @Override - @Nullable - public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); - if (liveSource != null) { - return liveSource; - } - - final List audioStreams = - getFilteredAudioStreams(context, info.getAudioStreams()); - final Stream stream; - final MediaItemTag tag; - - if (!audioStreams.isEmpty()) { - final int audioIndex = - ListHelper.getAudioFormatIndex(context, audioStreams, audioTrack); - stream = getStreamForIndex(audioIndex, audioStreams); - tag = StreamInfoTag.of(info, audioStreams, audioIndex); - } else { - final List videoStreams = - getPlayableStreams(info.getVideoStreams(), info.getServiceId()); - if (!videoStreams.isEmpty()) { - final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams); - stream = getStreamForIndex(index, videoStreams); - tag = StreamInfoTag.of(info); - } else { - return null; - } - } - - try { - return PlaybackResolver.buildMediaSource( - dataSource, stream, info, PlaybackResolver.cacheKeyOf(info, stream), tag); - } catch (final ResolverException e) { - Log.e(TAG, "Unable to create audio source", e); - return null; - } - } - - @Nullable - Stream getStreamForIndex(final int index, @NonNull final List streams) { - if (index >= 0 && index < streams.size()) { - return streams.get(index); - } - return null; - } - - @Nullable - public String getAudioTrack() { - return audioTrack; - } - - public void setAudioTrack(@Nullable final String audioLanguage) { - this.audioTrack = audioLanguage; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java deleted file mode 100644 index 7dc80a958..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ /dev/null @@ -1,566 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; -import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; -import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; - -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; - -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.services.youtube.ItagItem; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; -import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; -import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.mediaitem.StreamInfoTag; -import org.schabi.newpipe.util.StreamTypeUtil; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -/** - * This interface is just a shorthand for {@link Resolver} with {@link StreamInfo} as source and - * {@link MediaSource} as product. It contains many static methods that can be used by classes - * implementing this interface, and nothing else. - */ -public interface PlaybackResolver extends Resolver { - String TAG = PlaybackResolver.class.getSimpleName(); - - - //region Cache key generation - private static StringBuilder commonCacheKeyOf(final StreamInfo info, - final Stream stream, - final boolean resolutionOrBitrateUnknown) { - // stream info service id - final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); - - // stream info id - cacheKey.append(" "); - cacheKey.append(info.getId()); - - // stream id (even if unknown) - cacheKey.append(" "); - cacheKey.append(stream.getId()); - - // mediaFormat (if not null) - final MediaFormat mediaFormat = stream.getFormat(); - if (mediaFormat != null) { - cacheKey.append(" "); - cacheKey.append(mediaFormat.getName()); - } - - // content (only if other information is missing) - // If the media format and the resolution/bitrate are both missing, then we don't have - // enough information to distinguish this stream from other streams. - // So, only in that case, we use the content (i.e. url or manifest) to differentiate - // between streams. - // Note that if the content were used even when other information is present, then two - // streams with the same stats but with different contents (e.g. because the url was - // refreshed) will be considered different (i.e. with a different cacheKey), making the - // cache useless. - if (resolutionOrBitrateUnknown && mediaFormat == null) { - cacheKey.append(" "); - cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); - } - - return cacheKey; - } - - /** - * Builds the cache key of a {@link VideoStream video stream}. - * - *

- * A cache key is unique to the features of the provided video stream, and when possible - * independent of transient parameters (such as the URL of the stream). - * This ensures that there are no conflicts, but also that the cache is used as much as - * possible: the same cache should be used for two streams which have the same features but - * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream - * actually referenced by the URL is still the same. - *

- * - * @param info the {@link StreamInfo stream info}, to distinguish between streams with - * the same features but coming from different stream infos - * @param videoStream the {@link VideoStream video stream} for which the cache key should be - * created - * @return a key to be used to store the cache of the provided {@link VideoStream video stream} - */ - static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { - final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); - final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); - - // resolution (if known) - if (!resolutionUnknown) { - cacheKey.append(" "); - cacheKey.append(videoStream.getResolution()); - } - - // isVideoOnly - cacheKey.append(" "); - cacheKey.append(videoStream.isVideoOnly()); - - return cacheKey.toString(); - } - - /** - * Builds the cache key of an audio stream. - * - *

- * A cache key is unique to the features of the provided {@link AudioStream audio stream}, and - * when possible independent of transient parameters (such as the URL of the stream). - * This ensures that there are no conflicts, but also that the cache is used as much as - * possible: the same cache should be used for two streams which have the same features but - * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream - * actually referenced by the URL is still the same. - *

- * - * @param info the {@link StreamInfo stream info}, to distinguish between streams with - * the same features but coming from different stream infos - * @param audioStream the {@link AudioStream audio stream} for which the cache key should be - * created - * @return a key to be used to store the cache of the provided {@link AudioStream audio stream} - */ - static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { - final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; - final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); - - // averageBitrate (if known) - if (!averageBitrateUnknown) { - cacheKey.append(" "); - cacheKey.append(audioStream.getAverageBitrate()); - } - - if (audioStream.getAudioTrackId() != null) { - cacheKey.append(" "); - cacheKey.append(audioStream.getAudioTrackId()); - } - - if (audioStream.getAudioLocale() != null) { - cacheKey.append(" "); - cacheKey.append(audioStream.getAudioLocale().getISO3Language()); - } - - return cacheKey.toString(); - } - - /** - * Use common base type {@link Stream} to handle {@link AudioStream} or {@link VideoStream} - * transparently. For more info see {@link #cacheKeyOf(StreamInfo, AudioStream)} or - * {@link #cacheKeyOf(StreamInfo, VideoStream)}. - * - * @param info the {@link StreamInfo stream info}, to distinguish between streams with - * the same features but coming from different stream infos - * @param stream the {@link Stream} ({@link AudioStream} or {@link VideoStream}) - * for which the cache key should be created - * @return a key to be used to store the cache of the provided {@link Stream} - */ - static String cacheKeyOf(final StreamInfo info, final Stream stream) { - if (stream instanceof AudioStream) { - return cacheKeyOf(info, (AudioStream) stream); - } else if (stream instanceof VideoStream) { - return cacheKeyOf(info, (VideoStream) stream); - } - throw new RuntimeException("no audio or video stream. That should never happen"); - } - //endregion - - - //region Live media sources - @Nullable - static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, - final StreamInfo info) { - if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { - return null; - } - - try { - final StreamInfoTag tag = StreamInfoTag.of(info); - // Prefer DASH over HLS because of an exoPlayer bug that causes the background player to - // also fetch the video stream even if it is supposed to just fetch the audio stream. - if (!info.getDashMpdUrl().isEmpty()) { - return buildLiveMediaSource( - dataSource, info.getDashMpdUrl(), C.CONTENT_TYPE_DASH, tag); - } - if (!info.getHlsUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.CONTENT_TYPE_HLS, tag); - } - } catch (final Exception e) { - Log.w(TAG, "Error when generating live media source, falling back to standard sources", - e); - } - - return null; - } - - static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, - final String sourceUrl, - @C.ContentType final int type, - final MediaItemTag metadata) throws ResolverException { - final MediaSource.Factory factory; - switch (type) { - case C.CONTENT_TYPE_SS: - factory = dataSource.getLiveSsMediaSourceFactory(); - break; - case C.CONTENT_TYPE_DASH: - if (metadata.getServiceId() == ServiceList.YouTube.getServiceId()) { - factory = dataSource.getLiveYoutubeDashMediaSourceFactory(); - } else { - factory = dataSource.getLiveDashMediaSourceFactory(); - } - break; - case C.CONTENT_TYPE_HLS: - factory = dataSource.getLiveHlsMediaSourceFactory(); - break; - case C.CONTENT_TYPE_OTHER: - case C.CONTENT_TYPE_RTSP: - default: - throw new ResolverException("Unsupported type: " + type); - } - - return factory.createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(sourceUrl)) - .setLiveConfiguration( - new MediaItem.LiveConfiguration.Builder() - .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) - .build()) - .build()); - } - //endregion - - - //region Generic media sources - static MediaSource buildMediaSource(final PlayerDataSource dataSource, - final Stream stream, - final StreamInfo streamInfo, - final String cacheKey, - final MediaItemTag metadata) throws ResolverException { - if (streamInfo.getService() == ServiceList.YouTube) { - return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); - } - - final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); - switch (deliveryMethod) { - case PROGRESSIVE_HTTP: - return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata); - case DASH: - return buildDashMediaSource(dataSource, stream, cacheKey, metadata); - case HLS: - return buildHlsMediaSource(dataSource, stream, cacheKey, metadata); - case SS: - return buildSSMediaSource(dataSource, stream, cacheKey, metadata); - // Torrent streams are not supported by ExoPlayer - default: - throw new ResolverException("Unsupported delivery type: " + deliveryMethod); - } - } - - private static ProgressiveMediaSource buildProgressiveMediaSource( - final PlayerDataSource dataSource, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) throws ResolverException { - if (!stream.isUrl()) { - throw new ResolverException("Non URI progressive contents are not supported"); - } - throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); - return dataSource.getProgressiveMediaSourceFactory().createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - - private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) - throws ResolverException { - - if (stream.isUrl()) { - throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); - return dataSource.getDashMediaSourceFactory().createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - - try { - return dataSource.getDashMediaSourceFactory().createMediaSource( - createDashManifest(stream.getContent(), stream), - new MediaItem.Builder() - .setTag(metadata) - .setUri(manifestUrlToUri(stream.getManifestUrl())) - .setCustomCacheKey(cacheKey) - .build()); - } catch (final IOException e) { - throw new ResolverException( - "Could not create a DASH media source/manifest from the manifest text", e); - } - } - - private static DashManifest createDashManifest(final String manifestContent, - final Stream stream) throws IOException { - return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), - new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); - } - - private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) - throws ResolverException { - if (stream.isUrl()) { - throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); - return dataSource.getHlsMediaSourceFactory(null).createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - - final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = - new NonUriHlsDataSourceFactory.Builder(); - hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); - - return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) - .createMediaSource(new MediaItem.Builder() - .setTag(metadata) - .setUri(manifestUrlToUri(stream.getManifestUrl())) - .setCustomCacheKey(cacheKey) - .build()); - } - - private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) - throws ResolverException { - if (stream.isUrl()) { - throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); - return dataSource.getSSMediaSourceFactory().createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - - final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); - - final SsManifest smoothStreamingManifest; - try { - final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( - stream.getContent().getBytes(StandardCharsets.UTF_8)); - smoothStreamingManifest = new SsManifestParser().parse(manifestUri, - smoothStreamingManifestInput); - } catch (final IOException e) { - throw new ResolverException("Error when parsing manual SS manifest", e); - } - - return dataSource.getSSMediaSourceFactory().createMediaSource( - smoothStreamingManifest, - new MediaItem.Builder() - .setTag(metadata) - .setUri(manifestUri) - .setCustomCacheKey(cacheKey) - .build()); - } - //endregion - - - //region YouTube media sources - private static MediaSource createYoutubeMediaSource(final Stream stream, - final StreamInfo streamInfo, - final PlayerDataSource dataSource, - final String cacheKey, - final MediaItemTag metadata) - throws ResolverException { - if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { - throw new ResolverException("Generation of YouTube DASH manifest for " - + stream.getClass().getSimpleName() + " is not supported"); - } - - final StreamType streamType = streamInfo.getStreamType(); - if (streamType == StreamType.VIDEO_STREAM) { - return createYoutubeMediaSourceOfVideoStreamType(dataSource, stream, streamInfo, - cacheKey, metadata); - } else if (streamType == StreamType.POST_LIVE_STREAM) { - // If the content is not an URL, uses the DASH delivery method and if the stream type - // of the stream is a post live stream, it means that the content is an ended - // livestream so we need to generate the manifest corresponding to the content - // (which is the last segment of the stream) - - try { - final ItagItem itagItem = Objects.requireNonNull(stream.getItagItem()); - final String manifestString = YoutubePostLiveStreamDvrDashManifestCreator - .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), - itagItem, - itagItem.getTargetDurationSec(), - streamInfo.getDuration()); - return buildYoutubeManualDashMediaSource(dataSource, - createDashManifest(manifestString, stream), stream, cacheKey, - metadata); - } catch (final CreationException | IOException | NullPointerException e) { - throw new ResolverException( - "Error when generating the DASH manifest of YouTube ended live stream", e); - } - } else { - throw new ResolverException( - "DASH manifest generation of YouTube livestreams is not supported"); - } - } - - private static MediaSource createYoutubeMediaSourceOfVideoStreamType( - final PlayerDataSource dataSource, - final Stream stream, - final StreamInfo streamInfo, - final String cacheKey, - final MediaItemTag metadata) throws ResolverException { - final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); - switch (deliveryMethod) { - case PROGRESSIVE_HTTP: - if ((stream instanceof VideoStream && ((VideoStream) stream).isVideoOnly()) - || stream instanceof AudioStream) { - try { - final String manifestString = YoutubeProgressiveDashManifestCreator - .fromProgressiveStreamingUrl(stream.getContent(), - Objects.requireNonNull(stream.getItagItem()), - streamInfo.getDuration()); - return buildYoutubeManualDashMediaSource(dataSource, - createDashManifest(manifestString, stream), stream, cacheKey, - metadata); - } catch (final CreationException | IOException | NullPointerException e) { - Log.w(TAG, "Error when generating or parsing DASH manifest of " - + "YouTube progressive stream, falling back to a " - + "ProgressiveMediaSource.", e); - return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, - metadata); - } - } else { - // Legacy progressive streams, subtitles are handled by - // VideoPlaybackResolver - return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, - metadata); - } - case DASH: - // If the content is not a URL, uses the DASH delivery method and if the stream - // type of the stream is a video stream, it means the content is an OTF stream - // so we need to generate the manifest corresponding to the content (which is - // the base URL of the OTF stream). - - try { - final String manifestString = YoutubeOtfDashManifestCreator - .fromOtfStreamingUrl(stream.getContent(), - Objects.requireNonNull(stream.getItagItem()), - streamInfo.getDuration()); - return buildYoutubeManualDashMediaSource(dataSource, - createDashManifest(manifestString, stream), stream, cacheKey, - metadata); - } catch (final CreationException | IOException | NullPointerException e) { - Log.e(TAG, - "Error when generating the DASH manifest of YouTube OTF stream", e); - throw new ResolverException( - "Error when generating the DASH manifest of YouTube OTF stream", e); - } - case HLS: - return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - default: - throw new ResolverException("Unsupported delivery method for YouTube contents: " - + deliveryMethod); - } - } - - private static DashMediaSource buildYoutubeManualDashMediaSource( - final PlayerDataSource dataSource, - final DashManifest dashManifest, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) { - return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - - private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( - final PlayerDataSource dataSource, - final Stream stream, - final String cacheKey, - final MediaItemTag metadata) { - return dataSource.getYoutubeProgressiveMediaSourceFactory() - .createMediaSource(new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) - .setCustomCacheKey(cacheKey) - .build()); - } - //endregion - - - //region Utils - private static Uri manifestUrlToUri(final String manifestUrl) { - return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")); - } - - private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) - throws ResolverException { - if (url == null) { - throw new ResolverException("Null stream URL"); - } else if (url.isEmpty()) { - throw new ResolverException("Empty stream URL"); - } - } - //endregion - - - //region Resolver exception - final class ResolverException extends Exception { - public ResolverException(final String message) { - super(message); - } - - public ResolverException(final String message, final Throwable cause) { - super(message, cause); - } - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java deleted file mode 100644 index a3e1db5b4..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public interface Resolver { - @Nullable - Product resolve(@NonNull Source source); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java deleted file mode 100644 index 670c13934..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ /dev/null @@ -1,204 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import android.content.Context; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MergingMediaSource; - -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.mediaitem.StreamInfoTag; -import org.schabi.newpipe.util.ListHelper; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static com.google.android.exoplayer2.C.TIME_UNSET; -import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; -import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; -import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; - -public class VideoPlaybackResolver implements PlaybackResolver { - private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); - - @NonNull - private final Context context; - @NonNull - private final PlayerDataSource dataSource; - @NonNull - private final QualityResolver qualityResolver; - private SourceType streamSourceType; - - @Nullable - private String playbackQuality; - @Nullable - private String audioTrack; - - public enum SourceType { - LIVE_STREAM, - VIDEO_WITH_SEPARATED_AUDIO, - VIDEO_WITH_AUDIO_OR_AUDIO_ONLY - } - - public VideoPlaybackResolver(@NonNull final Context context, - @NonNull final PlayerDataSource dataSource, - @NonNull final QualityResolver qualityResolver) { - this.context = context; - this.dataSource = dataSource; - this.qualityResolver = qualityResolver; - } - - @Override - @Nullable - public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); - if (liveSource != null) { - streamSourceType = SourceType.LIVE_STREAM; - return liveSource; - } - - final List mediaSources = new ArrayList<>(); - - // Create video stream source - final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, - getPlayableStreams(info.getVideoStreams(), info.getServiceId()), - getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true); - final List audioStreamsList = - getFilteredAudioStreams(context, info.getAudioStreams()); - - final int videoIndex; - if (videoStreamsList.isEmpty()) { - videoIndex = -1; - } else if (playbackQuality == null) { - videoIndex = qualityResolver.getDefaultResolutionIndex(videoStreamsList); - } else { - videoIndex = qualityResolver.getOverrideResolutionIndex(videoStreamsList, - getPlaybackQuality()); - } - - final int audioIndex = - ListHelper.getAudioFormatIndex(context, audioStreamsList, audioTrack); - final MediaItemTag tag = - StreamInfoTag.of(info, videoStreamsList, videoIndex, audioStreamsList, audioIndex); - @Nullable final VideoStream video = tag.getMaybeQuality() - .map(MediaItemTag.Quality::getSelectedVideoStream) - .orElse(null); - @Nullable final AudioStream audio = tag.getMaybeAudioTrack() - .map(MediaItemTag.AudioTrack::getSelectedAudioStream) - .orElse(null); - - if (video != null) { - try { - final MediaSource streamSource = PlaybackResolver.buildMediaSource( - dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); - mediaSources.add(streamSource); - } catch (final ResolverException e) { - Log.e(TAG, "Unable to create video source", e); - return null; - } - } - - // Use the audio stream if there is no video stream, or - // merge with audio stream in case if video does not contain audio - if (audio != null && (video == null || video.isVideoOnly() || audioTrack != null)) { - try { - final MediaSource audioSource = PlaybackResolver.buildMediaSource( - dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); - mediaSources.add(audioSource); - streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; - } catch (final ResolverException e) { - Log.e(TAG, "Unable to create audio source", e); - return null; - } - } else { - streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; - } - - // If there is no audio or video sources, then this media source cannot be played back - if (mediaSources.isEmpty()) { - return null; - } - - // Below are auxiliary media sources - - // Create subtitle sources - final List subtitlesStreams = info.getSubtitles(); - if (subtitlesStreams != null) { - // Torrent and non URL subtitles are not supported by ExoPlayer - final List nonTorrentAndUrlStreams = getUrlAndNonTorrentStreams( - subtitlesStreams); - for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { - final MediaFormat mediaFormat = subtitle.getFormat(); - if (mediaFormat != null) { - @C.RoleFlags final int textRoleFlag = subtitle.isAutoGenerated() - ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND - : C.ROLE_FLAG_CAPTION; - final MediaItem.SubtitleConfiguration textMediaItem = - new MediaItem.SubtitleConfiguration.Builder( - Uri.parse(subtitle.getContent())) - .setMimeType(mediaFormat.getMimeType()) - .setRoleFlags(textRoleFlag) - .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) - .build(); - final MediaSource textSource = dataSource.getSingleSampleMediaSourceFactory() - .createMediaSource(textMediaItem, TIME_UNSET); - mediaSources.add(textSource); - } - } - } - - if (mediaSources.size() == 1) { - return mediaSources.get(0); - } else { - return new MergingMediaSource(true, mediaSources.toArray(new MediaSource[0])); - } - } - - /** - * Returns the last resolved {@link StreamInfo}'s {@link SourceType source type}. - * - * @return {@link Optional#empty()} if nothing was resolved, otherwise the {@link SourceType} - * of the last resolved {@link StreamInfo} inside an {@link Optional} - */ - public Optional getStreamSourceType() { - return Optional.ofNullable(streamSourceType); - } - - @Nullable - public String getPlaybackQuality() { - return playbackQuality; - } - - public void setPlaybackQuality(@Nullable final String playbackQuality) { - this.playbackQuality = playbackQuality; - } - - @Nullable - public String getAudioTrack() { - return audioTrack; - } - - public void setAudioTrack(@Nullable final String audioLanguage) { - this.audioTrack = audioLanguage; - } - - public interface QualityResolver { - int getDefaultResolutionIndex(List sortedVideos); - - int getOverrideResolutionIndex(List sortedVideos, String playbackQuality); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java deleted file mode 100644 index 28856d606..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.schabi.newpipe.player.seekbarpreview; - -import android.content.Context; -import android.graphics.Bitmap; -import android.util.Log; -import android.view.View; -import android.widget.ImageView; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.graphics.BitmapCompat; -import androidx.core.math.MathUtils; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.DeviceUtils; - -import java.lang.annotation.Retention; -import java.util.function.IntSupplier; - -import static java.lang.annotation.RetentionPolicy.SOURCE; -import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.HIGH_QUALITY; -import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.LOW_QUALITY; -import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.NONE; - -/** - * Helper for the seekbar preview. - */ -public final class SeekbarPreviewThumbnailHelper { - - // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) - // or it fails with an IllegalArgumentException - // https://stackoverflow.com/a/54744028 - public static final String TAG = "SeekbarPrevThumbHelper"; - - private SeekbarPreviewThumbnailHelper() { - // No impl pls - } - - @Retention(SOURCE) - @IntDef({HIGH_QUALITY, LOW_QUALITY, - NONE}) - public @interface SeekbarPreviewThumbnailType { - int HIGH_QUALITY = 0; - int LOW_QUALITY = 1; - int NONE = 2; - } - - //////////////////////////////////////////////////////////////////////////// - // Settings Resolution - /////////////////////////////////////////////////////////////////////////// - - @SeekbarPreviewThumbnailType - public static int getSeekbarPreviewThumbnailType(@NonNull final Context context) { - final String type = PreferenceManager.getDefaultSharedPreferences(context).getString( - context.getString(R.string.seekbar_preview_thumbnail_key), ""); - if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_none))) { - return NONE; - } else if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_low_quality))) { - return LOW_QUALITY; - } else { - return HIGH_QUALITY; // default - } - } - - public static void tryResizeAndSetSeekbarPreviewThumbnail( - @NonNull final Context context, - @Nullable final Bitmap previewThumbnail, - @NonNull final ImageView currentSeekbarPreviewThumbnail, - @NonNull final IntSupplier baseViewWidthSupplier) { - if (previewThumbnail == null) { - currentSeekbarPreviewThumbnail.setVisibility(View.GONE); - return; - } - - currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE); - - // Resize original bitmap - try { - final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1; - final int newWidth = MathUtils.clamp( - // Use 1/4 of the width for the preview - Math.round(baseViewWidthSupplier.getAsInt() / 4f), - // But have a min width of 10dp - DeviceUtils.dpToPx(10, context), - // And scaling more than that factor looks really pixelated -> max - Math.round(srcWidth * 2.5f)); - - final float scaleFactor = (float) newWidth / srcWidth; - final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor); - - currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat - .createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true)); - } catch (final Exception ex) { - Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex); - currentSeekbarPreviewThumbnail.setVisibility(View.GONE); - } finally { - previewThumbnail.recycle(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java deleted file mode 100644 index d9e25fe8b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java +++ /dev/null @@ -1,247 +0,0 @@ -package org.schabi.newpipe.player.seekbarpreview; - -import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType; -import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType; - -import android.content.Context; -import android.graphics.Bitmap; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.SparseArrayCompat; - -import com.google.common.base.Stopwatch; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.extractor.stream.Frameset; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.function.Supplier; - -public class SeekbarPreviewThumbnailHolder { - - // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) - // or it fails with an IllegalArgumentException - // https://stackoverflow.com/a/54744028 - public static final String TAG = "SeekbarPrevThumbHolder"; - - // Key = Position of the picture in milliseconds - // Supplier = Supplies the bitmap for that position - private final SparseArrayCompat> seekbarPreviewData = - new SparseArrayCompat<>(); - - // This ensures that if the reset is still undergoing - // and another reset starts, only the last reset is processed - private UUID currentUpdateRequestIdentifier = UUID.randomUUID(); - - public void resetFrom(@NonNull final Context context, final List framesets) { - final int seekbarPreviewType = getSeekbarPreviewThumbnailType(context); - - final UUID updateRequestIdentifier = UUID.randomUUID(); - this.currentUpdateRequestIdentifier = updateRequestIdentifier; - - final ExecutorService executorService = Executors.newSingleThreadExecutor(); - executorService.submit(() -> { - try { - resetFromAsync(seekbarPreviewType, framesets, updateRequestIdentifier); - } catch (final Exception ex) { - Log.e(TAG, "Failed to execute async", ex); - } - }); - // ensure that the executorService stops/destroys it's threads - // after the task is finished - executorService.shutdown(); - } - - private void resetFromAsync(final int seekbarPreviewType, final List framesets, - final UUID updateRequestIdentifier) { - Log.d(TAG, "Clearing seekbarPreviewData"); - synchronized (seekbarPreviewData) { - seekbarPreviewData.clear(); - } - - if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) { - Log.d(TAG, "Not processing seekbarPreviewData due to settings"); - return; - } - - final Frameset frameset = getFrameSetForType(framesets, seekbarPreviewType); - if (frameset == null) { - Log.d(TAG, "No frameset was found to fill seekbarPreviewData"); - return; - } - - Log.d(TAG, "Frameset quality info: " - + "[width=" + frameset.getFrameWidth() - + ", heigh=" + frameset.getFrameHeight() + "]"); - - // Abort method execution if we are not the latest request - if (!isRequestIdentifierCurrent(updateRequestIdentifier)) { - return; - } - - generateDataFrom(frameset, updateRequestIdentifier); - } - - private Frameset getFrameSetForType(final List framesets, - final int seekbarPreviewType) { - if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) { - Log.d(TAG, "Strategy for seekbarPreviewData: high quality"); - return framesets.stream() - .max(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) - .orElse(null); - } else { - Log.d(TAG, "Strategy for seekbarPreviewData: low quality"); - return framesets.stream() - .min(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) - .orElse(null); - } - } - - private void generateDataFrom(final Frameset frameset, final UUID updateRequestIdentifier) { - Log.d(TAG, "Starting generation of seekbarPreviewData"); - final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; - - int currentPosMs = 0; - int pos = 1; - - final int urlFrameCount = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); - - // Process each url in the frameset - for (final String url : frameset.getUrls()) { - // get the bitmap - final Bitmap srcBitMap = getBitMapFrom(url); - - // The data is not added directly to "seekbarPreviewData" due to - // concurrency and checks for "updateRequestIdentifier" - final var generatedDataForUrl = new SparseArrayCompat>(urlFrameCount); - - // The bitmap consists of several images, which we process here - // foreach frame in the returned bitmap - for (int i = 0; i < urlFrameCount; i++) { - // Frames outside the video length are skipped - if (pos > frameset.getTotalCount()) { - break; - } - - // Get the bounds where the frame is found - final int[] bounds = frameset.getFrameBoundsAt(currentPosMs); - generatedDataForUrl.put(currentPosMs, - createBitmapSupplier(srcBitMap, bounds, frameset)); - - currentPosMs += frameset.getDurationPerFrame(); - pos++; - } - - // Check if we are still the latest request - // If not abort method execution - if (isRequestIdentifierCurrent(updateRequestIdentifier)) { - synchronized (seekbarPreviewData) { - seekbarPreviewData.putAll(generatedDataForUrl); - } - } else { - Log.d(TAG, "Aborted of generation of seekbarPreviewData"); - break; - } - } - - if (sw != null) { - Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop()); - } - } - - private Supplier createBitmapSupplier(final Bitmap srcBitMap, - final int[] bounds, - final Frameset frameset) { - return () -> { - // It can happen, that the original bitmap could not be downloaded - // (or it was recycled though that should not happen) - // In such a case - we don't want a NullPointer/ - // "cannot use a recycled source in createBitmap" Exception -> simply return null - if (srcBitMap == null || srcBitMap.isRecycled()) { - return null; - } - - // Under some rare circumstances the YouTube API returns slightly too small storyboards, - // (or not the matching frame width/height) - // This would lead to createBitmap cutting out a bitmap that is out of bounds, - // so we need to adjust the bounds accordingly - if (srcBitMap.getWidth() < bounds[1] + frameset.getFrameWidth()) { - bounds[1] = srcBitMap.getWidth() - frameset.getFrameWidth(); - } - - if (srcBitMap.getHeight() < bounds[2] + frameset.getFrameHeight()) { - bounds[2] = srcBitMap.getHeight() - frameset.getFrameHeight(); - } - - // Cut out the corresponding bitmap form the "srcBitMap" - final Bitmap cutOutBitmap = Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2], - frameset.getFrameWidth(), frameset.getFrameHeight()); - - // If the cut out bitmap is identical to its source, - // we need to copy the bitmap to create a new instance. - // createBitmap allows itself to return the original object that is was created with - // this leads to recycled bitmaps being returned (if they are identical) - // Reference: https://stackoverflow.com/a/23683075 + first comment - // Fixes: https://github.com/TeamNewPipe/NewPipe/issues/11461 - return cutOutBitmap == srcBitMap - ? cutOutBitmap.copy(cutOutBitmap.getConfig(), true) : cutOutBitmap; - }; - } - - @Nullable - private Bitmap getBitMapFrom(final String url) { - if (url == null) { - Log.w(TAG, "url is null; This should never happen"); - return null; - } - - final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; - try { - Log.d(TAG, "Downloading bitmap for seekbarPreview from '" + url + "'"); - - // Gets the bitmap within the timeout of 15 seconds imposed by default by OkHttpClient - // Ensure that you are not running on the main thread, otherwise this will hang - final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), url); - - if (sw != null) { - Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took " - + sw.stop()); - } - - return bitmap; - } catch (final Exception ex) { - Log.w(TAG, "Failed to get bitmap for seekbarPreview from url='" + url - + "' in time", ex); - return null; - } - } - - private boolean isRequestIdentifierCurrent(final UUID requestIdentifier) { - return this.currentUpdateRequestIdentifier.equals(requestIdentifier); - } - - public Optional getBitmapAt(final int positionInMs) { - // Get the frame supplier closest to the requested position - Supplier closestFrame = () -> null; - synchronized (seekbarPreviewData) { - int min = Integer.MAX_VALUE; - for (int i = 0; i < seekbarPreviewData.size(); i++) { - final int pos = Math.abs(seekbarPreviewData.keyAt(i) - positionInMs); - if (pos < min) { - closestFrame = seekbarPreviewData.valueAt(i); - min = pos; - } - } - } - - return Optional.ofNullable(closestFrame.get()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/BackgroundPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/BackgroundPlayerUi.java deleted file mode 100644 index 4172df35e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/BackgroundPlayerUi.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.player.Player; - -/** - * This is not a "graphical" UI for the background player, but it is used to disable fetching video - * and text tracks with it. - * - *

- * This allows reducing data usage for manifest sources with demuxed audio and video, - * such as livestreams. - *

- */ -public class BackgroundPlayerUi extends PlayerUi { - - public BackgroundPlayerUi(@NonNull final Player player) { - super(player); - } - - @Override - public void initPlayback() { - super.initPlayback(); - - // Make sure to disable video and subtitles track types - player.useVideoAndSubtitles(false); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java deleted file mode 100644 index 4d85dc950..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ /dev/null @@ -1,980 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; -import static org.schabi.newpipe.extractor.ServiceList.YouTube; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.player.Player.STATE_COMPLETED; -import static org.schabi.newpipe.player.Player.STATE_PAUSED; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; -import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction; -import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; -import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.database.ContentObserver; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.fragment.app.FragmentActivity; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.exoplayer2.ui.SubtitleView; -import com.google.android.exoplayer2.video.VideoSize; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PlayerBinding; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamSegment; -import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.info_list.StreamSegmentAdapter; -import org.schabi.newpipe.info_list.StreamSegmentItem; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.event.PlayerServiceEventListener; -import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; -import org.schabi.newpipe.player.gesture.MainPlayerGestureListener; -import org.schabi.newpipe.player.helper.PlaybackParameterDialog; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutChangeListener { - private static final String TAG = MainPlayerUi.class.getSimpleName(); - - // see the Javadoc of calculateMaxEndScreenThumbnailHeight for information - private static final int DETAIL_ROOT_MINIMUM_HEIGHT = 85; // dp - private static final int DETAIL_TITLE_TEXT_SIZE_TV = 16; // sp - private static final int DETAIL_TITLE_TEXT_SIZE_TABLET = 15; // sp - - private boolean isFullscreen = false; - private boolean isVerticalVideo = false; - private boolean fragmentIsVisible = false; - - private ContentObserver settingsContentObserver; - - private PlayQueueAdapter playQueueAdapter; - private StreamSegmentAdapter segmentAdapter; - private boolean isQueueVisible = false; - private boolean areSegmentsVisible = false; - - // fullscreen player - private ItemTouchHelper itemTouchHelper; - - - /*////////////////////////////////////////////////////////////////////////// - // Constructor, setup, destroy - //////////////////////////////////////////////////////////////////////////*/ - //region Constructor, setup, destroy - - public MainPlayerUi(@NonNull final Player player, - @NonNull final PlayerBinding playerBinding) { - super(player, playerBinding); - } - - /** - * Open fullscreen on tablets where the option to have the main player start automatically in - * fullscreen mode is on. Rotating the device to landscape is already done in {@link - * VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's - * enough for phones, but not for tablets since the mini player can be also shown in landscape. - */ - private void directlyOpenFullscreenIfNeeded() { - if (PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService()) - && DeviceUtils.isTablet(player.getService()) - && PlayerHelper.globalScreenOrientationLocked(player.getService())) { - player.getFragmentListener().ifPresent( - PlayerServiceEventListener::onScreenRotationButtonClicked); - } - } - - @Override - public void setupAfterIntent() { - // needed for tablets, check the function for a better explanation - directlyOpenFullscreenIfNeeded(); - - super.setupAfterIntent(); - - initVideoPlayer(); - // Android TV: without it focus will frame the whole player - binding.playPauseButton.requestFocus(); - - // Note: This is for automatically playing (when "Resume playback" is off), see #6179 - if (player.getPlayWhenReady()) { - player.play(); - } else { - player.pause(); - } - } - - @Override - BasePlayerGestureListener buildGestureListener() { - return new MainPlayerGestureListener(this); - } - - @Override - protected void initListeners() { - super.initListeners(); - - binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> { - // Only if it's not a vertical video or vertical video but in landscape with locked - // orientation a screen orientation can be changed automatically - if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { - player.getFragmentListener() - .ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked); - } else { - toggleFullscreen(); - } - })); - binding.queueButton.setOnClickListener(v -> onQueueClicked()); - binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); - - binding.addToPlaylistButton.setOnClickListener(v -> - getParentActivity().map(FragmentActivity::getSupportFragmentManager) - .ifPresent(fragmentManager -> - PlaylistDialog.showForPlayQueue(player, fragmentManager))); - - settingsContentObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { - @Override - public void onChange(final boolean selfChange) { - setupScreenRotationButton(); - } - }; - context.getContentResolver().registerContentObserver( - Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, - settingsContentObserver); - - binding.getRoot().addOnLayoutChangeListener(this); - - binding.moreOptionsButton.setOnLongClickListener(v -> { - player.getFragmentListener() - .ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked); - hideControls(0, 0); - hideSystemUIIfNeeded(); - return true; - }); - } - - @Override - protected void deinitListeners() { - super.deinitListeners(); - - binding.queueButton.setOnClickListener(null); - binding.segmentsButton.setOnClickListener(null); - binding.addToPlaylistButton.setOnClickListener(null); - - context.getContentResolver().unregisterContentObserver(settingsContentObserver); - - binding.getRoot().removeOnLayoutChangeListener(this); - } - - @Override - public void initPlayback() { - super.initPlayback(); - - if (playQueueAdapter != null) { - playQueueAdapter.dispose(); - } - playQueueAdapter = new PlayQueueAdapter(context, - Objects.requireNonNull(player.getPlayQueue())); - segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); - - // Make sure video and text tracks are enabled if the user is in the app, in the case user - // switched from background player to main player - player.useVideoAndSubtitles(fragmentIsVisible); - } - - @Override - public void removeViewFromParent() { - // view was added to fragment - final ViewParent parent = binding.getRoot().getParent(); - if (parent instanceof ViewGroup) { - ((ViewGroup) parent).removeView(binding.getRoot()); - } - } - - @Override - public void destroy() { - super.destroy(); - - // Exit from fullscreen when user closes the player via notification - if (isFullscreen) { - toggleFullscreen(); - } - - removeViewFromParent(); - } - - @Override - public void destroyPlayer() { - super.destroyPlayer(); - - if (playQueueAdapter != null) { - playQueueAdapter.unsetSelectedListener(); - playQueueAdapter.dispose(); - } - } - - @Override - public void smoothStopForImmediateReusing() { - super.smoothStopForImmediateReusing(); - // Android TV will handle back button in case controls will be visible - // (one more additional unneeded click while the player is hidden) - hideControls(0, 0); - closeItemsList(); - } - - private void initVideoPlayer() { - // restore last resize mode - setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player)); - binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); - } - - @Override - protected void setupElementsVisibility() { - super.setupElementsVisibility(); - - closeItemsList(); - showHideKodiButton(); - binding.fullScreenButton.setVisibility(View.GONE); - setupScreenRotationButton(); - binding.resizeTextView.setVisibility(View.VISIBLE); - binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); - binding.moreOptionsButton.setVisibility(View.VISIBLE); - binding.topControls.setOrientation(LinearLayout.VERTICAL); - binding.primaryControls.getLayoutParams().width = MATCH_PARENT; - binding.secondaryControls.setVisibility(View.INVISIBLE); - binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, - R.drawable.ic_expand_more)); - binding.share.setVisibility(View.VISIBLE); - binding.openInBrowser.setVisibility(View.VISIBLE); - binding.switchMute.setVisibility(View.VISIBLE); - binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); - // Top controls have a large minHeight which is allows to drag the player - // down in fullscreen mode (just larger area to make easy to locate by finger) - binding.topControls.setClickable(true); - binding.topControls.setFocusable(true); - - binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); - - // Reset workaround changes from popup player - binding.audioTrackTextView.setMaxWidth(Integer.MAX_VALUE); - } - - @Override - protected void setupElementsSize(final Resources resources) { - setupElementsSize( - resources.getDimensionPixelSize(R.dimen.player_main_buttons_min_width), - resources.getDimensionPixelSize(R.dimen.player_main_top_padding), - resources.getDimensionPixelSize(R.dimen.player_main_controls_padding), - resources.getDimensionPixelSize(R.dimen.player_main_buttons_padding) - ); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Broadcast receiver - //////////////////////////////////////////////////////////////////////////*/ - //region Broadcast receiver - - @Override - public void onBroadcastReceived(final Intent intent) { - super.onBroadcastReceived(intent); - if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { - // Close it because when changing orientation from portrait - // (in fullscreen mode) the size of queue layout can be larger than the screen size - closeItemsList(); - } else if (ACTION_PLAY_PAUSE.equals(intent.getAction())) { - // Ensure that we have audio-only stream playing when a user - // started to play from notification's play button from outside of the app - if (!fragmentIsVisible) { - onFragmentStopped(); - } - } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED.equals(intent.getAction())) { - fragmentIsVisible = false; - onFragmentStopped(); - } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED.equals(intent.getAction())) { - // Restore video source when user returns to the fragment - fragmentIsVisible = true; - player.useVideoAndSubtitles(true); - - // When a user returns from background, the system UI will always be shown even if - // controls are invisible: hide it in that case - if (!isControlsVisible()) { - hideSystemUIIfNeeded(); - } - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Fragment binding - //////////////////////////////////////////////////////////////////////////*/ - //region Fragment binding - - @Override - public void onFragmentListenerSet() { - super.onFragmentListenerSet(); - fragmentIsVisible = true; - // Apply window insets because Android will not do it when orientation changes - // from landscape to portrait - if (!isFullscreen) { - binding.playbackControlRoot.setPadding(0, 0, 0, 0); - } - binding.itemsListPanel.setPadding(0, 0, 0, 0); - player.getFragmentListener().ifPresent(PlayerServiceEventListener::onViewCreated); - } - - /** - * This will be called when a user goes to another app/activity, turns off a screen. - * We don't want to interrupt playback and don't want to see notification so - * next lines of code will enable audio-only playback only if needed - */ - private void onFragmentStopped() { - if (player.isPlaying() || player.isLoading()) { - switch (getMinimizeOnExitAction(context)) { - case MINIMIZE_ON_EXIT_MODE_BACKGROUND: - player.useVideoAndSubtitles(false); - break; - case MINIMIZE_ON_EXIT_MODE_POPUP: - getParentActivity().ifPresent(activity -> { - player.setRecovery(); - NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true); - }); - break; - case MINIMIZE_ON_EXIT_MODE_NONE: default: - player.pause(); - break; - } - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Playback states - //////////////////////////////////////////////////////////////////////////*/ - //region Playback states - - @Override - public void onUpdateProgress(final int currentProgress, - final int duration, - final int bufferPercent) { - super.onUpdateProgress(currentProgress, duration, bufferPercent); - - if (areSegmentsVisible) { - segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); - } - if (isQueueVisible) { - updateQueueTime(currentProgress); - } - } - - @Override - public void onPlaying() { - super.onPlaying(); - checkLandscape(); - } - - @Override - public void onCompleted() { - super.onCompleted(); - if (isFullscreen) { - toggleFullscreen(); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Controls showing / hiding - //////////////////////////////////////////////////////////////////////////*/ - //region Controls showing / hiding - - @Override - protected void showOrHideButtons() { - super.showOrHideButtons(); - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue == null) { - return; - } - - final boolean showQueue = !playQueue.getStreams().isEmpty(); - final boolean showSegment = !player.getCurrentStreamInfo() - .map(StreamInfo::getStreamSegments) - .map(List::isEmpty) - .orElse(/*no stream info=*/true); - - binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); - binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); - binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); - binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); - } - - @Override - public void showSystemUIPartially() { - if (isFullscreen) { - getParentActivity().map(Activity::getWindow).ifPresent(window -> { - window.setStatusBarColor(Color.TRANSPARENT); - window.setNavigationBarColor(Color.TRANSPARENT); - final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; - window.getDecorView().setSystemUiVisibility(visibility); - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - }); - } - } - - @Override - public void hideSystemUIIfNeeded() { - player.getFragmentListener().ifPresent(PlayerServiceEventListener::hideSystemUiIfNeeded); - } - - /** - * Calculate the maximum allowed height for the {@link R.id.endScreen} - * to prevent it from enlarging the player. - *

- * The calculating follows these rules: - *

    - *
  • - * Show at least stream title and content creator on TVs and tablets when in landscape - * (always the case for TVs) and not in fullscreen mode. This requires to have at least - * {@link #DETAIL_ROOT_MINIMUM_HEIGHT} free space for {@link R.id.detail_root} and - * additional space for the stream title text size ({@link R.id.detail_title_root_layout}). - * The text size is {@link #DETAIL_TITLE_TEXT_SIZE_TABLET} on tablets and - * {@link #DETAIL_TITLE_TEXT_SIZE_TV} on TVs, see {@link R.id.titleTextView}. - *
  • - *
  • - * Otherwise, the max thumbnail height is the screen height. - *
  • - *
- * - * @param bitmap the bitmap that needs to be resized to fit the end screen - * @return the maximum height for the end screen thumbnail - */ - @Override - protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { - final int screenHeight = context.getResources().getDisplayMetrics().heightPixels; - - if (DeviceUtils.isTv(context) && !isFullscreen()) { - final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) - + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context); - return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); - } else if (DeviceUtils.isTablet(context) && isLandscape() && !isFullscreen()) { - final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) - + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context); - return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); - } else { // fullscreen player: max height is the device height - return Math.min(bitmap.getHeight(), screenHeight); - } - } - - private void showHideKodiButton() { - // show kodi button if it supports the current service and it is enabled in settings - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - binding.playWithKodi.setVisibility(playQueue != null && playQueue.getItem() != null - && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) - ? View.VISIBLE : View.GONE); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Captions (text tracks) - //////////////////////////////////////////////////////////////////////////*/ - //region Captions (text tracks) - - @Override - protected void setupSubtitleView(final float captionScale) { - binding.subtitleView.setFractionalTextSize( - SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Gestures - //////////////////////////////////////////////////////////////////////////*/ - //region Gestures - - @SuppressWarnings("checkstyle:ParameterNumber") - @Override - public void onLayoutChange(final View view, final int l, final int t, final int r, final int b, - final int ol, final int ot, final int or, final int ob) { - if (l != ol || t != ot || r != or || b != ob) { - // Use a smaller value to be consistent across screen orientations, and to make usage - // easier. Multiply by 3/4 to ensure the user does not need to move the finger up to the - // screen border, in order to reach the maximum volume/brightness. - final int width = r - l; - final int height = b - t; - final int min = Math.min(width, height); - final int maxGestureLength = (int) (min * 0.75); - - if (DEBUG) { - Log.d(TAG, "maxGestureLength = " + maxGestureLength); - } - - binding.volumeProgressBar.setMax(maxGestureLength); - binding.brightnessProgressBar.setMax(maxGestureLength); - - setInitialGestureValues(); - binding.itemsListPanel.getLayoutParams().height = - height - binding.itemsListPanel.getTop(); - } - } - - private void setInitialGestureValues() { - if (player.getAudioReactor() != null) { - final float currentVolumeNormalized = (float) player.getAudioReactor().getVolume() - / player.getAudioReactor().getMaxVolume(); - binding.volumeProgressBar.setProgress( - (int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized)); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Play queue, segments and streams - //////////////////////////////////////////////////////////////////////////*/ - //region Play queue, segments and streams - - @Override - public void onMetadataChanged(@NonNull final StreamInfo info) { - super.onMetadataChanged(info); - showHideKodiButton(); - if (areSegmentsVisible) { - if (segmentAdapter.setItems(info)) { - final int adapterPosition = getNearestStreamSegmentPosition( - player.getExoPlayer().getCurrentPosition()); - segmentAdapter.selectSegmentAt(adapterPosition); - binding.itemsList.scrollToPosition(adapterPosition); - } else { - closeItemsList(); - } - } - } - - @Override - public void onPlayQueueEdited() { - super.onPlayQueueEdited(); - showOrHideButtons(); - } - - private void onQueueClicked() { - isQueueVisible = true; - - hideSystemUIIfNeeded(); - buildQueue(); - - binding.itemsListHeaderTitle.setVisibility(View.GONE); - binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); - binding.shuffleButton.setVisibility(View.VISIBLE); - binding.repeatButton.setVisibility(View.VISIBLE); - binding.addToPlaylistButton.setVisibility(View.VISIBLE); - - hideControls(0, 0); - binding.itemsListPanel.requestFocus(); - animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA); - - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue != null) { - binding.itemsList.scrollToPosition(playQueue.getIndex()); - } - - updateQueueTime((int) player.getExoPlayer().getCurrentPosition()); - } - - private void buildQueue() { - binding.itemsList.setAdapter(playQueueAdapter); - binding.itemsList.setClickable(true); - binding.itemsList.setLongClickable(true); - - binding.itemsList.clearOnScrollListeners(); - binding.itemsList.addOnScrollListener(getQueueScrollListener()); - - itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(binding.itemsList); - - playQueueAdapter.setSelectedListener(getOnSelectedListener()); - - binding.itemsListClose.setOnClickListener(view -> closeItemsList()); - } - - private void onSegmentsClicked() { - areSegmentsVisible = true; - - hideSystemUIIfNeeded(); - buildSegments(); - - binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); - binding.itemsListHeaderDuration.setVisibility(View.GONE); - binding.shuffleButton.setVisibility(View.GONE); - binding.repeatButton.setVisibility(View.GONE); - binding.addToPlaylistButton.setVisibility(View.GONE); - - hideControls(0, 0); - binding.itemsListPanel.requestFocus(); - animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA); - - final int adapterPosition = getNearestStreamSegmentPosition( - player.getExoPlayer().getCurrentPosition()); - segmentAdapter.selectSegmentAt(adapterPosition); - binding.itemsList.scrollToPosition(adapterPosition); - } - - private void buildSegments() { - binding.itemsList.setAdapter(segmentAdapter); - binding.itemsList.setClickable(true); - binding.itemsList.setLongClickable(true); - - binding.itemsList.clearOnScrollListeners(); - if (itemTouchHelper != null) { - itemTouchHelper.attachToRecyclerView(null); - } - - player.getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); - - binding.shuffleButton.setVisibility(View.GONE); - binding.repeatButton.setVisibility(View.GONE); - binding.addToPlaylistButton.setVisibility(View.GONE); - binding.itemsListClose.setOnClickListener(view -> closeItemsList()); - } - - public void closeItemsList() { - if (isQueueVisible || areSegmentsVisible) { - isQueueVisible = false; - areSegmentsVisible = false; - - if (itemTouchHelper != null) { - itemTouchHelper.attachToRecyclerView(null); - } - - animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA, 0, () -> - // Even when queueLayout is GONE it receives touch events - // and ruins normal behavior of the app. This line fixes it - binding.itemsListPanel.setTranslationY( - -binding.itemsListPanel.getHeight() * 5.0f)); - - // clear focus, otherwise a white rectangle remains on top of the player - binding.itemsListClose.clearFocus(); - binding.playPauseButton.requestFocus(); - } - } - - private OnScrollBelowItemsListener getQueueScrollListener() { - return new OnScrollBelowItemsListener() { - @Override - public void onScrolledDown(final RecyclerView recyclerView) { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue != null && !playQueue.isComplete()) { - playQueue.fetch(); - } else if (binding != null) { - binding.itemsList.clearOnScrollListeners(); - } - } - }; - } - - private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { - return new StreamSegmentAdapter.StreamSegmentListener() { - @Override - public void onItemClick(@NonNull final StreamSegmentItem item, final int seconds) { - segmentAdapter.selectSegment(item); - player.seekTo(seconds * 1000L); - player.triggerProgressUpdate(); - } - - @Override - public void onItemLongClick(@NonNull final StreamSegmentItem item, final int seconds) { - @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); - if (currentMetadata == null - || currentMetadata.getServiceId() != YouTube.getServiceId()) { - return; - } - - final PlayQueueItem currentItem = player.getCurrentItem(); - if (currentItem != null) { - String videoUrl = player.getVideoUrl(); - videoUrl += ("&t=" + seconds); - ShareUtils.shareText(context, currentItem.getTitle(), - videoUrl, currentItem.getThumbnails()); - } - } - }; - } - - private int getNearestStreamSegmentPosition(final long playbackPosition) { - final List segments = player.getCurrentStreamInfo() - .map(StreamInfo::getStreamSegments) - .orElse(Collections.emptyList()); - - int nearestPosition = 0; - for (final var segment : segments) { - if (segment.getStartTimeSeconds() * 1000L > playbackPosition) { - break; - } - nearestPosition++; - } - return Math.max(0, nearestPosition - 1); - } - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new PlayQueueItemTouchCallback() { - @Override - public void onMove(final int sourceIndex, final int targetIndex) { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue != null) { - playQueue.move(sourceIndex, targetIndex); - } - } - - @Override - public void onSwiped(final int index) { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue != null && index != -1) { - playQueue.remove(index); - } - } - }; - } - - private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { - return new PlayQueueItemBuilder.OnSelectedListener() { - @Override - public void selected(final PlayQueueItem item, final View view) { - player.selectQueueItem(item); - } - - @Override - public void held(final PlayQueueItem item, final View view) { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); - if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, true, - parentActivity.getSupportFragmentManager(), context); - } - } - - @Override - public void onStartDrag(final PlayQueueItemHolder viewHolder) { - if (itemTouchHelper != null) { - itemTouchHelper.startDrag(viewHolder); - } - } - }; - } - - private void updateQueueTime(final int currentTime) { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue == null) { - return; - } - - final int currentStream = playQueue.getIndex(); - final List streams = playQueue.getStreams(); - - final long before = streams.subList(0, currentStream).stream() - .collect(Collectors.summingLong(PlayQueueItem::getDuration)) * 1000; - - final long after = streams.subList(currentStream, streams.size()).stream() - .collect(Collectors.summingLong(PlayQueueItem::getDuration)) * 1000; - - binding.itemsListHeaderDuration.setText( - String.format("%s/%s", - getTimeString(currentTime + before), - getTimeString(before + after) - )); - } - - @Override - protected boolean isAnyListViewOpen() { - return isQueueVisible || areSegmentsVisible; - } - - @Override - public boolean isFullscreen() { - return isFullscreen; - } - - public boolean isVerticalVideo() { - return isVerticalVideo; - } - - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Click listeners - //////////////////////////////////////////////////////////////////////////*/ - //region Click listeners - - @Override - protected void onPlaybackSpeedClicked() { - getParentActivity().ifPresent(activity -> - PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), - player.getPlaybackPitch(), player.getPlaybackSkipSilence(), - player::setPlaybackParameters) - .show(activity.getSupportFragmentManager(), null)); - } - - @Override - public boolean onKeyDown(final int keyCode) { - if (keyCode == KeyEvent.KEYCODE_SPACE && isFullscreen) { - player.playPause(); - if (player.isPlaying()) { - hideControls(0, 0); - } - return true; - } - return super.onKeyDown(keyCode); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Video size, orientation, fullscreen - //////////////////////////////////////////////////////////////////////////*/ - //region Video size, orientation, fullscreen - - private void setupScreenRotationButton() { - binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context) - || isVerticalVideo || DeviceUtils.isTablet(context) - ? View.VISIBLE : View.GONE); - binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, - isFullscreen ? R.drawable.ic_fullscreen_exit - : R.drawable.ic_fullscreen)); - } - - @Override - public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { - super.onVideoSizeChanged(videoSize); - isVerticalVideo = videoSize.width < videoSize.height; - - if (globalScreenOrientationLocked(context) - && isFullscreen - && isLandscape() == isVerticalVideo - && !DeviceUtils.isTv(context) - && !DeviceUtils.isTablet(context)) { - // set correct orientation - player.getFragmentListener().ifPresent( - PlayerServiceEventListener::onScreenRotationButtonClicked); - } - - setupScreenRotationButton(); - } - - public void toggleFullscreen() { - if (DEBUG) { - Log.d(TAG, "toggleFullscreen() called"); - } - final PlayerServiceEventListener fragmentListener = player.getFragmentListener() - .orElse(null); - if (fragmentListener == null || player.exoPlayerIsNull()) { - return; - } - - isFullscreen = !isFullscreen; - if (isFullscreen) { - // Android needs tens milliseconds to send new insets but a user is able to see - // how controls changes it's position from `0` to `nav bar height` padding. - // So just hide the controls to hide this visual inconsistency - hideControls(0, 0); - } else { - // Apply window insets because Android will not do it when orientation changes - // from landscape to portrait (open vertical video to reproduce) - binding.playbackControlRoot.setPadding(0, 0, 0, 0); - } - fragmentListener.onFullscreenStateChanged(isFullscreen); - - binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); - binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); - setupScreenRotationButton(); - } - - public void checkLandscape() { - // check if landscape is correct - final boolean videoInLandscapeButNotInFullscreen = isLandscape() - && !isFullscreen - && !player.isAudioOnly(); - final boolean notPaused = player.getCurrentState() != STATE_COMPLETED - && player.getCurrentState() != STATE_PAUSED; - - if (videoInLandscapeButNotInFullscreen - && notPaused - && !DeviceUtils.isTablet(context)) { - toggleFullscreen(); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Getters - //////////////////////////////////////////////////////////////////////////*/ - //region Getters - - private Optional getParentContext() { - return Optional.ofNullable(binding.getRoot().getParent()) - .filter(ViewGroup.class::isInstance) - .map(parent -> ((ViewGroup) parent).getContext()); - } - - public Optional getParentActivity() { - return getParentContext() - .filter(AppCompatActivity.class::isInstance) - .map(AppCompatActivity.class::cast); - } - - public boolean isLandscape() { - // DisplayMetrics from activity context knows about MultiWindow feature - // while DisplayMetrics from app context doesn't - return DeviceUtils.isLandscape(getParentContext().orElse(player.getService())); - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java deleted file mode 100644 index 57e2ec2a2..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java +++ /dev/null @@ -1,212 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.RepeatMode; -import com.google.android.exoplayer2.Tracks; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.video.VideoSize; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.Player; - -import java.util.List; - -/** - * A player UI is a component that can seamlessly connect and disconnect from the {@link Player} and - * provide a user interface of some sort. Try to extend this class instead of adding more code to - * {@link Player}! - */ -public abstract class PlayerUi { - - @NonNull protected final Context context; - @NonNull protected final Player player; - - /** - * @param player the player instance that will be usable throughout the lifetime of this UI; its - * context should already have been initialized - */ - protected PlayerUi(@NonNull final Player player) { - this.context = player.getContext(); - this.player = player; - } - - /** - * @return the player instance this UI was constructed with - */ - @NonNull - public Player getPlayer() { - return player; - } - - - /** - * Called after the player received an intent and processed it. - */ - public void setupAfterIntent() { - } - - /** - * Called right after the exoplayer instance is constructed, or right after this UI is - * constructed if the exoplayer is already available then. Note that the exoplayer instance - * could be built and destroyed multiple times during the lifetime of the player, so this method - * might be called multiple times. - */ - public void initPlayer() { - } - - /** - * Called when playback in the exoplayer is about to start, or right after this UI is - * constructed if the exoplayer and the play queue are already available then. The play queue - * will therefore always be not null. - */ - public void initPlayback() { - } - - /** - * Called when the exoplayer instance is about to be destroyed. Note that the exoplayer instance - * could be built and destroyed multiple times during the lifetime of the player, so this method - * might be called multiple times. Be sure to unset any video surface view or play queue - * listeners! This will also be called when this UI is being discarded, just before {@link - * #destroy()}. - */ - public void destroyPlayer() { - } - - /** - * Called when this UI is being discarded, either because the player is switching to a different - * UI or because the player is shutting down completely. - */ - public void destroy() { - } - - /** - * Called when the player is smooth-stopping, that is, transitioning smoothly to a new play - * queue after the user tapped on a new video stream while a stream was playing in the video - * detail fragment. - */ - public void smoothStopForImmediateReusing() { - } - - /** - * Called when the video detail fragment listener is connected with the player, or right after - * this UI is constructed if the listener is already connected then. - */ - public void onFragmentListenerSet() { - } - - /** - * Broadcasts that the player receives will also be notified to UIs here. If you want to - * register new broadcast actions to receive here, add them to {@link - * Player#setupBroadcastReceiver()}. - * @param intent the broadcast intent received by the player - */ - public void onBroadcastReceived(final Intent intent) { - } - - /** - * Called when stream progress (i.e. the current time in the seekbar) or stream duration change. - * Will surely be called every {@link Player#PROGRESS_LOOP_INTERVAL_MILLIS} while a stream is - * playing. - * @param currentProgress the current progress in milliseconds - * @param duration the duration of the stream being played - * @param bufferPercent the percentage of stream already buffered, see {@link - * com.google.android.exoplayer2.BasePlayer#getBufferedPercentage()} - */ - public void onUpdateProgress(final int currentProgress, - final int duration, - final int bufferPercent) { - } - - public void onPrepared() { - } - - public void onBlocked() { - } - - public void onPlaying() { - } - - public void onBuffering() { - } - - public void onPaused() { - } - - public void onPausedSeek() { - } - - public void onCompleted() { - } - - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - } - - public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { - } - - public void onMuteUnmuteChanged(final boolean isMuted) { - } - - /** - * @see com.google.android.exoplayer2.Player.Listener#onTracksChanged(Tracks) - * @param currentTracks the available tracks information - */ - public void onTextTracksChanged(@NonNull final Tracks currentTracks) { - } - - /** - * @see com.google.android.exoplayer2.Player.Listener#onPlaybackParametersChanged - * @param playbackParameters the new playback parameters - */ - public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { - } - - /** - * @see com.google.android.exoplayer2.Player.Listener#onRenderedFirstFrame - */ - public void onRenderedFirstFrame() { - } - - /** - * @see com.google.android.exoplayer2.text.TextOutput#onCues - * @param cues the cues to pass to the subtitle view - */ - public void onCues(@NonNull final List cues) { - } - - /** - * Called when the stream being played changes. - * @param info the {@link StreamInfo} metadata object, along with data about the selected and - * available video streams (to be used to build the resolution menus, for example) - */ - public void onMetadataChanged(@NonNull final StreamInfo info) { - } - - /** - * Called when the thumbnail for the current metadata was loaded. - * @param bitmap the thumbnail to process, or null if there is no thumbnail or there was an - * error when loading the thumbnail - */ - public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - } - - /** - * Called when the play queue was edited: a stream was appended, moved or removed. - */ - public void onPlayQueueEdited() { - } - - /** - * @param videoSize the new video size, useful to set the surface aspect ratio - * @see com.google.android.exoplayer2.Player.Listener#onVideoSizeChanged - */ - public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java deleted file mode 100644 index 24fec3b8a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerUiList { - final List playerUis = new ArrayList<>(); - - /** - * Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis - * will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when - * the {@link PlayerUiList} constructor is called, the player is still not running and it - * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing - * proper calls to {@link #call(Consumer)}. - * - * @param initialPlayerUis the player uis this list should start with; the order will be kept - */ - public PlayerUiList(final PlayerUi... initialPlayerUis) { - playerUis.addAll(List.of(initialPlayerUis)); - } - - /** - * Adds the provided player ui to the list and calls on it the initialization functions that - * apply based on the current player state. The preparation step needs to be done since when UIs - * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer - * is already initialized, but we need to notify the newly built UI that the player is ready - * nonetheless. - * @param playerUi the player ui to prepare and add to the list; its {@link - * PlayerUi#getPlayer()} will be used to query information about the player - * state - */ - public void addAndPrepare(final PlayerUi playerUi) { - if (playerUi.getPlayer().getFragmentListener().isPresent()) { - // make sure UIs know whether a service is connected or not - playerUi.onFragmentListenerSet(); - } - - if (!playerUi.getPlayer().exoPlayerIsNull()) { - playerUi.initPlayer(); - if (playerUi.getPlayer().getPlayQueue() != null) { - playerUi.initPlayback(); - } - } - - playerUis.add(playerUi); - } - - /** - * Destroys all matching player UIs and removes them from the list. - * @param playerUiType the class of the player UI to destroy; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses will be - * destroyed and removed - * @param the class type parameter - */ - public void destroyAll(final Class playerUiType) { - playerUis.stream() - .filter(playerUiType::isInstance) - .forEach(playerUi -> { - playerUi.destroyPlayer(); - playerUi.destroy(); - }); - playerUis.removeIf(playerUiType::isInstance); - } - - /** - * @param playerUiType the class of the player UI to return; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses could - * be returned - * @param the class type parameter - * @return the first player UI of the required type found in the list, or an empty {@link - * Optional} otherwise - */ - public Optional get(final Class playerUiType) { - return playerUis.stream() - .filter(playerUiType::isInstance) - .map(playerUiType::cast) - .findFirst(); - } - - /** - * Calls the provided consumer on all player UIs in the list, in order of addition. - * @param consumer the consumer to call with player UIs - */ - public void call(final Consumer consumer) { - //noinspection SimplifyStreamApiCallChains - playerUis.stream().forEachOrdered(consumer); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java deleted file mode 100644 index b9c29c008..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java +++ /dev/null @@ -1,602 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.PixelFormat; -import android.os.Build; -import android.util.DisplayMetrics; -import android.util.Log; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.view.animation.AnticipateInterpolator; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.core.math.MathUtils; - -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.SubtitleView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PlayerBinding; -import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; -import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.util.DeviceUtils; - -public final class PopupPlayerUi extends VideoPlayerUi { - private static final String TAG = PopupPlayerUi.class.getSimpleName(); - - /** - * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using - * NewPipe's popup player. - * - *

- * This value is hardcoded instead of being get dynamically with the method linked of the - * constant documentation below, because it is not static and popup player layout parameters - * are generated with static methods. - *

- * - * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE - */ - private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f; - - /*////////////////////////////////////////////////////////////////////////// - // Popup player - //////////////////////////////////////////////////////////////////////////*/ - - private PlayerPopupCloseOverlayBinding closeOverlayBinding; - - private boolean isPopupClosing = false; - - private int screenWidth; - private int screenHeight; - - /*////////////////////////////////////////////////////////////////////////// - // Popup player window manager - //////////////////////////////////////////////////////////////////////////*/ - - public static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; - public static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS - | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; - - private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup - private final WindowManager windowManager; - - - /*////////////////////////////////////////////////////////////////////////// - // Constructor, setup, destroy - //////////////////////////////////////////////////////////////////////////*/ - //region Constructor, setup, destroy - - public PopupPlayerUi(@NonNull final Player player, - @NonNull final PlayerBinding playerBinding) { - super(player, playerBinding); - windowManager = ContextCompat.getSystemService(context, WindowManager.class); - } - - @Override - public void setupAfterIntent() { - super.setupAfterIntent(); - initPopup(); - initPopupCloseOverlay(); - } - - @Override - BasePlayerGestureListener buildGestureListener() { - return new PopupPlayerGestureListener(this); - } - - @SuppressLint("RtlHardcoded") - private void initPopup() { - if (DEBUG) { - Log.d(TAG, "initPopup() called"); - } - - // Popup is already added to windowManager - if (popupHasParent()) { - return; - } - - updateScreenSize(); - - popupLayoutParams = retrievePopupLayoutParamsFromPrefs(); - binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); - - checkPopupPositionBounds(); - - binding.loadingPanel.setMinimumWidth(popupLayoutParams.width); - binding.loadingPanel.setMinimumHeight(popupLayoutParams.height); - - windowManager.addView(binding.getRoot(), popupLayoutParams); - setupVideoSurfaceIfNeeded(); // now there is a parent, we can setup video surface - - // Popup doesn't have aspectRatio selector, using FIT automatically - setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); - } - - @SuppressLint("RtlHardcoded") - private void initPopupCloseOverlay() { - if (DEBUG) { - Log.d(TAG, "initPopupCloseOverlay() called"); - } - - // closeOverlayView is already added to windowManager - if (closeOverlayBinding != null) { - return; - } - - closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context)); - - final WindowManager.LayoutParams closeOverlayLayoutParams = buildCloseOverlayLayoutParams(); - closeOverlayBinding.closeButton.setVisibility(View.GONE); - windowManager.addView(closeOverlayBinding.getRoot(), closeOverlayLayoutParams); - } - - @Override - public void initPlayback() { - super.initPlayback(); - // Make sure video and text tracks are enabled if the screen is turned on (which should - // always be the case), in the case user switched from background player to popup player - player.useVideoAndSubtitles(player.isScreenOn()); - } - - @Override - protected void setupElementsVisibility() { - binding.fullScreenButton.setVisibility(View.VISIBLE); - binding.screenRotationButton.setVisibility(View.GONE); - binding.resizeTextView.setVisibility(View.GONE); - binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE); - binding.queueButton.setVisibility(View.GONE); - binding.segmentsButton.setVisibility(View.GONE); - binding.moreOptionsButton.setVisibility(View.GONE); - binding.topControls.setOrientation(LinearLayout.HORIZONTAL); - binding.primaryControls.getLayoutParams().width = WRAP_CONTENT; - binding.secondaryControls.setAlpha(1.0f); - binding.secondaryControls.setVisibility(View.VISIBLE); - binding.secondaryControls.setTranslationY(0); - binding.share.setVisibility(View.GONE); - binding.playWithKodi.setVisibility(View.GONE); - binding.openInBrowser.setVisibility(View.GONE); - binding.switchMute.setVisibility(View.GONE); - binding.playerCloseButton.setVisibility(View.GONE); - binding.topControls.bringToFront(); - binding.topControls.setClickable(false); - binding.topControls.setFocusable(false); - binding.bottomControls.bringToFront(); - // Workaround that UI elements are pushed off screen - binding.audioTrackTextView.setMaxWidth(DeviceUtils.dpToPx(48, context)); - super.setupElementsVisibility(); - } - - @Override - protected void setupElementsSize(final Resources resources) { - setupElementsSize( - 0, - 0, - resources.getDimensionPixelSize(R.dimen.player_popup_controls_padding), - resources.getDimensionPixelSize(R.dimen.player_popup_buttons_padding) - ); - } - - @Override - public void removeViewFromParent() { - // view was added by windowManager for popup player - windowManager.removeViewImmediate(binding.getRoot()); - } - - @Override - public void destroy() { - super.destroy(); - removePopupFromView(); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Broadcast receiver - //////////////////////////////////////////////////////////////////////////*/ - //region Broadcast receiver - - @Override - public void onBroadcastReceived(final Intent intent) { - super.onBroadcastReceived(intent); - if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { - updateScreenSize(); - changePopupSize(popupLayoutParams.width); - checkPopupPositionBounds(); - } else if (player.isPlaying() || player.isLoading()) { - if (Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { - // Use only audio source when screen turns off while popup player is playing - player.useVideoAndSubtitles(false); - } else if (Intent.ACTION_SCREEN_ON.equals(intent.getAction())) { - // Restore video source when screen turns on and user was watching video in popup - player.useVideoAndSubtitles(true); - } - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Popup position and size - //////////////////////////////////////////////////////////////////////////*/ - //region Popup position and size - - /** - * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary - * that goes from (0, 0) to (screenWidth, screenHeight). - *

- * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed - * and {@code true} is returned to represent this change. - *

- */ - public void checkPopupPositionBounds() { - if (DEBUG) { - Log.d(TAG, "checkPopupPositionBounds() called with: " - + "screenWidth = [" + screenWidth + "], " - + "screenHeight = [" + screenHeight + "]"); - } - if (popupLayoutParams == null) { - return; - } - - popupLayoutParams.x = MathUtils.clamp(popupLayoutParams.x, 0, screenWidth - - popupLayoutParams.width); - popupLayoutParams.y = MathUtils.clamp(popupLayoutParams.y, 0, screenHeight - - popupLayoutParams.height); - } - - public void updateScreenSize() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - final var windowMetrics = windowManager.getCurrentWindowMetrics(); - final var bounds = windowMetrics.getBounds(); - final var windowInsets = windowMetrics.getWindowInsets(); - final var insets = windowInsets.getInsetsIgnoringVisibility( - WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); - screenWidth = bounds.width() - (insets.left + insets.right); - screenHeight = bounds.height() - (insets.top + insets.bottom); - } else { - final DisplayMetrics metrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(metrics); - screenWidth = metrics.widthPixels; - screenHeight = metrics.heightPixels; - } - if (DEBUG) { - Log.d(TAG, "updateScreenSize() called: screenWidth = [" - + screenWidth + "], screenHeight = [" + screenHeight + "]"); - } - } - - /** - * Changes the size of the popup based on the width. - * @param width the new width, height is calculated with - * {@link PlayerHelper#getMinimumVideoHeight(float)} - */ - public void changePopupSize(final int width) { - if (DEBUG) { - Log.d(TAG, "changePopupSize() called with: width = [" + width + "]"); - } - - if (anyPopupViewIsNull()) { - return; - } - - final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width); - final int actualWidth = MathUtils.clamp(width, (int) minimumWidth, screenWidth); - final int actualHeight = (int) getMinimumVideoHeight(width); - if (DEBUG) { - Log.d(TAG, "updatePopupSize() updated values:" - + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); - } - - popupLayoutParams.width = actualWidth; - popupLayoutParams.height = actualHeight; - binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); - windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); - } - - @Override - protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { - // no need for the end screen thumbnail to be resized on popup player: it's only needed - // for the main player so that it is enlarged correctly inside the fragment - return bitmap.getHeight(); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Popup closing - //////////////////////////////////////////////////////////////////////////*/ - //region Popup closing - - public void closePopup() { - if (DEBUG) { - Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); - } - if (isPopupClosing) { - return; - } - isPopupClosing = true; - - player.saveStreamProgressState(); - windowManager.removeView(binding.getRoot()); - - animatePopupOverlayAndFinishService(); - } - - public boolean isPopupClosing() { - return isPopupClosing; - } - - public void removePopupFromView() { - // wrap in try-catch since it could sometimes generate errors randomly - try { - if (popupHasParent()) { - windowManager.removeView(binding.getRoot()); - } - } catch (final IllegalArgumentException e) { - Log.w(TAG, "Failed to remove popup from window manager", e); - } - - try { - final boolean closeOverlayHasParent = closeOverlayBinding != null - && closeOverlayBinding.getRoot().getParent() != null; - if (closeOverlayHasParent) { - windowManager.removeView(closeOverlayBinding.getRoot()); - } - } catch (final IllegalArgumentException e) { - Log.w(TAG, "Failed to remove popup overlay from window manager", e); - } - } - - private void animatePopupOverlayAndFinishService() { - final int targetTranslationY = - (int) (closeOverlayBinding.closeButton.getRootView().getHeight() - - closeOverlayBinding.closeButton.getY()); - - closeOverlayBinding.closeButton.animate().setListener(null).cancel(); - closeOverlayBinding.closeButton.animate() - .setInterpolator(new AnticipateInterpolator()) - .translationY(targetTranslationY) - .setDuration(400) - .setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationCancel(final Animator animation) { - end(); - } - - @Override - public void onAnimationEnd(final Animator animation) { - end(); - } - - private void end() { - windowManager.removeView(closeOverlayBinding.getRoot()); - closeOverlayBinding = null; - player.getService().destroyPlayerAndStopService(); - } - }).start(); - } - //endregion - - /*////////////////////////////////////////////////////////////////////////// - // Playback states - //////////////////////////////////////////////////////////////////////////*/ - //region Playback states - - private void changePopupWindowFlags(final int flags) { - if (DEBUG) { - Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]"); - } - - if (!anyPopupViewIsNull()) { - popupLayoutParams.flags = flags; - windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); - } - } - - @Override - public void onPlaying() { - super.onPlaying(); - changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); - } - - @Override - public void onPaused() { - super.onPaused(); - changePopupWindowFlags(IDLE_WINDOW_FLAGS); - } - - @Override - public void onCompleted() { - super.onCompleted(); - changePopupWindowFlags(IDLE_WINDOW_FLAGS); - } - - @Override - protected void setupSubtitleView(final float captionScale) { - binding.subtitleView.setFractionalTextSize( - SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale); - } - - @Override - protected void onPlaybackSpeedClicked() { - playbackSpeedPopupMenu.show(); - isSomePopupMenuVisible = true; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Gestures - //////////////////////////////////////////////////////////////////////////*/ - //region Gestures - - private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) { - final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft() - + closeOverlayBinding.closeButton.getWidth() / 2; - final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop() - + closeOverlayBinding.closeButton.getHeight() / 2; - - final float fingerX = popupLayoutParams.x + popupMotionEvent.getX(); - final float fingerY = popupLayoutParams.y + popupMotionEvent.getY(); - - return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2) - + Math.pow(closeOverlayButtonY - fingerY, 2)); - } - - private float getClosingRadius() { - final int buttonRadius = closeOverlayBinding.closeButton.getWidth() / 2; - // 20% wider than the button itself - return buttonRadius * 1.2f; - } - - public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) { - return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius(); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Popup & closing overlay layout params + saving popup position and size - //////////////////////////////////////////////////////////////////////////*/ - //region Popup & closing overlay layout params + saving popup position and size - - /** - * {@code screenWidth} and {@code screenHeight} must have been initialized. - * @return the popup starting layout params - */ - @SuppressLint("RtlHardcoded") - public WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs() { - final SharedPreferences prefs = getPlayer().getPrefs(); - final Context context = getPlayer().getContext(); - - final boolean popupRememberSizeAndPos = prefs.getBoolean( - context.getString(R.string.popup_remember_size_pos_key), true); - final float defaultSize = context.getResources().getDimension(R.dimen.popup_default_width); - final float popupWidth = popupRememberSizeAndPos - ? prefs.getFloat(context.getString(R.string.popup_saved_width_key), defaultSize) - : defaultSize; - final float popupHeight = getMinimumVideoHeight(popupWidth); - - final WindowManager.LayoutParams params = new WindowManager.LayoutParams( - (int) popupWidth, (int) popupHeight, - popupLayoutParamType(), - IDLE_WINDOW_FLAGS, - PixelFormat.TRANSLUCENT); - params.gravity = Gravity.LEFT | Gravity.TOP; - params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; - - final int centerX = (int) (screenWidth / 2f - popupWidth / 2f); - final int centerY = (int) (screenHeight / 2f - popupHeight / 2f); - params.x = popupRememberSizeAndPos - ? prefs.getInt(context.getString(R.string.popup_saved_x_key), centerX) : centerX; - params.y = popupRememberSizeAndPos - ? prefs.getInt(context.getString(R.string.popup_saved_y_key), centerY) : centerY; - - return params; - } - - public void savePopupPositionAndSizeToPrefs() { - if (getPopupLayoutParams() != null) { - final Context context = getPlayer().getContext(); - getPlayer().getPrefs().edit() - .putFloat(context.getString(R.string.popup_saved_width_key), - popupLayoutParams.width) - .putInt(context.getString(R.string.popup_saved_x_key), - popupLayoutParams.x) - .putInt(context.getString(R.string.popup_saved_y_key), - popupLayoutParams.y) - .apply(); - } - } - - @SuppressLint("RtlHardcoded") - public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() { - final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE - | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; - - final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, - popupLayoutParamType(), - flags, - PixelFormat.TRANSLUCENT); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Setting maximum opacity allowed for touch events to other apps for Android 12 and - // higher to prevent non interaction when using other apps with the popup player - closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER; - } - - closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; - closeOverlayLayoutParams.softInputMode = - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; - return closeOverlayLayoutParams; - } - - public static int popupLayoutParamType() { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.O - ? WindowManager.LayoutParams.TYPE_PHONE - : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Getters - //////////////////////////////////////////////////////////////////////////*/ - //region Getters - - private boolean popupHasParent() { - return binding != null - && binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams - && binding.getRoot().getParent() != null; - } - - private boolean anyPopupViewIsNull() { - return popupLayoutParams == null || windowManager == null - || binding.getRoot().getParent() == null; - } - - public PlayerPopupCloseOverlayBinding getCloseOverlayBinding() { - return closeOverlayBinding; - } - - public WindowManager.LayoutParams getPopupLayoutParams() { - return popupLayoutParams; - } - - public WindowManager getWindowManager() { - return windowManager; - } - - public int getScreenHeight() { - return screenHeight; - } - - public int getScreenWidth() { - return screenWidth; - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java deleted file mode 100644 index 0bb01b10d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ /dev/null @@ -1,1642 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.Player.RENDERER_UNAVAILABLE; -import static org.schabi.newpipe.player.Player.STATE_BUFFERING; -import static org.schabi.newpipe.player.Player.STATE_COMPLETED; -import static org.schabi.newpipe.player.Player.STATE_PAUSED; -import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; -import static org.schabi.newpipe.player.Player.STATE_PLAYING; -import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; -import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; -import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; -import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; - -import android.content.Intent; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.net.Uri; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; -import android.view.GestureDetector; -import android.view.Gravity; -import android.view.KeyEvent; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.SeekBar; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.appcompat.widget.PopupMenu; -import androidx.core.graphics.BitmapCompat; -import androidx.core.graphics.Insets; -import androidx.core.math.MathUtils; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.RepeatMode; -import com.google.android.exoplayer2.Tracks; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.CaptionStyleCompat; -import com.google.android.exoplayer2.video.VideoSize; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.PlayerBinding; -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; -import org.schabi.newpipe.player.gesture.DisplayPortion; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.mediaitem.MediaItemTag; -import org.schabi.newpipe.player.playback.SurfaceHolderCallback; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper; -import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, - PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { - private static final String TAG = VideoPlayerUi.class.getSimpleName(); - - // time constants - public static final long DEFAULT_CONTROLS_DURATION = 300; // 300 millis - public static final long DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds - public static final long DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds - public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis - - // other constants (TODO remove playback speeds and use normal menu for popup, too) - private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f}; - - private enum PlayButtonAction { - PLAY, PAUSE, REPLAY - } - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - protected PlayerBinding binding; - private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper()); - @Nullable - private SurfaceHolderCallback surfaceHolderCallback; - boolean surfaceIsSetup = false; - - - /*////////////////////////////////////////////////////////////////////////// - // Popup menus ("popup" means that they pop up, not that they belong to the popup player) - //////////////////////////////////////////////////////////////////////////*/ - - private static final int POPUP_MENU_ID_QUALITY = 69; - private static final int POPUP_MENU_ID_AUDIO_TRACK = 70; - private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79; - private static final int POPUP_MENU_ID_CAPTION = 89; - - protected boolean isSomePopupMenuVisible = false; - private PopupMenu qualityPopupMenu; - private PopupMenu audioTrackPopupMenu; - protected PopupMenu playbackSpeedPopupMenu; - private PopupMenu captionPopupMenu; - - - /*////////////////////////////////////////////////////////////////////////// - // Gestures - //////////////////////////////////////////////////////////////////////////*/ - - private GestureDetector gestureDetector; - private BasePlayerGestureListener playerGestureListener; - @Nullable - private View.OnLayoutChangeListener onLayoutChangeListener = null; - - @NonNull - private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = - new SeekbarPreviewThumbnailHolder(); - - - /*////////////////////////////////////////////////////////////////////////// - // Constructor, setup, destroy - //////////////////////////////////////////////////////////////////////////*/ - //region Constructor, setup, destroy - - protected VideoPlayerUi(@NonNull final Player player, - @NonNull final PlayerBinding playerBinding) { - super(player); - binding = playerBinding; - setupFromView(); - } - - public void setupFromView() { - initViews(); - initListeners(); - setupPlayerSeekOverlay(); - } - - private void initViews() { - setupSubtitleView(); - - binding.resizeTextView - .setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode())); - - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - binding.playbackSeekBar.getProgressDrawable() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)); - - final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, - R.style.DarkPopupMenu); - - qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView); - audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView); - playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed); - captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView); - - binding.progressBarLoadingPanel.getIndeterminateDrawable() - .setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY)); - - binding.titleTextView.setSelected(true); - binding.channelTextView.setSelected(true); - - // Prevent hiding of bottom sheet via swipe inside queue - binding.itemsList.setNestedScrollingEnabled(false); - } - - abstract BasePlayerGestureListener buildGestureListener(); - - protected void initListeners() { - binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked)); - binding.audioTrackTextView.setOnClickListener( - makeOnClickListener(this::onAudioTracksClicked)); - binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)); - - binding.playbackSeekBar.setOnSeekBarChangeListener(this); - binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)); - binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)); - binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)); - - playerGestureListener = buildGestureListener(); - gestureDetector = new GestureDetector(context, playerGestureListener); - binding.getRoot().setOnTouchListener(playerGestureListener); - - binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); - binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); - - binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)); - binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)); - binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)); - - binding.moreOptionsButton.setOnClickListener( - makeOnClickListener(this::onMoreOptionsClicked)); - binding.share.setOnClickListener(makeOnClickListener(() -> { - final PlayQueueItem currentItem = player.getCurrentItem(); - if (currentItem != null) { - ShareUtils.shareText(context, currentItem.getTitle(), - player.getVideoUrlAtCurrentTime(), currentItem.getThumbnails()); - } - })); - binding.share.setOnLongClickListener(v -> { - ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); - return true; - }); - binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> { - player.setRecovery(); - NavigationHelper.playOnMainPlayer(context, - Objects.requireNonNull(player.getPlayQueue()), true); - })); - binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)); - binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)); - binding.playerCloseButton.setOnClickListener(makeOnClickListener(() -> - // set package to this app's package to prevent the intent from being seen outside - context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) - .setPackage(App.PACKAGE_NAME)) - )); - binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); - - ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { - final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); - if (!cutout.equals(Insets.NONE)) { - view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom); - } - return windowInsets; - }); - - // PlaybackControlRoot already consumed window insets but we should pass them to - // player_overlays and fast_seek_overlay too. Without it they will be off-centered. - onLayoutChangeListener = - (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - binding.playerOverlays.setPadding(v.getPaddingLeft(), v.getPaddingTop(), - v.getPaddingRight(), v.getPaddingBottom()); - - // If we added padding to the fast seek overlay, too, it would not go under the - // system ui. Instead we apply negative margins equal to the window insets of - // the opposite side, so that the view covers all of the player (overflowing on - // some sides) and its center coincides with the center of other controls. - final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams) - binding.fastSeekOverlay.getLayoutParams(); - fastSeekParams.leftMargin = -v.getPaddingRight(); - fastSeekParams.topMargin = -v.getPaddingBottom(); - fastSeekParams.rightMargin = -v.getPaddingLeft(); - fastSeekParams.bottomMargin = -v.getPaddingTop(); - }; - binding.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener); - } - - protected void deinitListeners() { - binding.qualityTextView.setOnClickListener(null); - binding.audioTrackTextView.setOnClickListener(null); - binding.playbackSpeed.setOnClickListener(null); - binding.playbackSeekBar.setOnSeekBarChangeListener(null); - binding.captionTextView.setOnClickListener(null); - binding.resizeTextView.setOnClickListener(null); - binding.playbackLiveSync.setOnClickListener(null); - - binding.getRoot().setOnTouchListener(null); - playerGestureListener = null; - gestureDetector = null; - - binding.repeatButton.setOnClickListener(null); - binding.shuffleButton.setOnClickListener(null); - - binding.playPauseButton.setOnClickListener(null); - binding.playPreviousButton.setOnClickListener(null); - binding.playNextButton.setOnClickListener(null); - - binding.moreOptionsButton.setOnClickListener(null); - binding.moreOptionsButton.setOnLongClickListener(null); - binding.share.setOnClickListener(null); - binding.share.setOnLongClickListener(null); - binding.fullScreenButton.setOnClickListener(null); - binding.screenRotationButton.setOnClickListener(null); - binding.playWithKodi.setOnClickListener(null); - binding.openInBrowser.setOnClickListener(null); - binding.playerCloseButton.setOnClickListener(null); - binding.switchMute.setOnClickListener(null); - - ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, null); - - binding.playbackControlRoot.removeOnLayoutChangeListener(onLayoutChangeListener); - } - - /** - * Initializes the Fast-For/Backward overlay. - */ - private void setupPlayerSeekOverlay() { - binding.fastSeekOverlay - .seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(player) / 1000) - .performListener(new PlayerFastSeekOverlay.PerformListener() { - - @Override - public void onDoubleTap() { - animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION); - } - - @Override - public void onDoubleTapEnd() { - animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION); - } - - @NonNull - @Override - public FastSeekDirection getFastSeekDirection( - @NonNull final DisplayPortion portion - ) { - if (player.exoPlayerIsNull()) { - // Abort seeking - playerGestureListener.endMultiDoubleTap(); - return FastSeekDirection.NONE; - } - if (portion == DisplayPortion.LEFT) { - // Check if it's possible to rewind - // Small puffer to eliminate infinite rewind seeking - if (player.getExoPlayer().getCurrentPosition() < 500L) { - return FastSeekDirection.NONE; - } - return FastSeekDirection.BACKWARD; - } else if (portion == DisplayPortion.RIGHT) { - // Check if it's possible to fast-forward - if (player.getCurrentState() == STATE_COMPLETED - || player.getExoPlayer().getCurrentPosition() - >= player.getExoPlayer().getDuration()) { - return FastSeekDirection.NONE; - } - return FastSeekDirection.FORWARD; - } - /* portion == DisplayPortion.MIDDLE */ - return FastSeekDirection.NONE; - } - - @Override - public void seek(final boolean forward) { - playerGestureListener.keepInDoubleTapMode(); - if (forward) { - player.fastForward(); - } else { - player.fastRewind(); - } - } - }); - playerGestureListener.doubleTapControls(binding.fastSeekOverlay); - } - - public void deinitPlayerSeekOverlay() { - binding.fastSeekOverlay - .seekSecondsSupplier(null) - .performListener(null); - } - - @Override - public void setupAfterIntent() { - super.setupAfterIntent(); - setupElementsVisibility(); - setupElementsSize(context.getResources()); - binding.getRoot().setVisibility(View.VISIBLE); - binding.playPauseButton.requestFocus(); - } - - @Override - public void initPlayer() { - super.initPlayer(); - setupVideoSurfaceIfNeeded(); - } - - @Override - public void initPlayback() { - super.initPlayback(); - - // #6825 - Ensure that the shuffle-button is in the correct state on the UI - setShuffleButton(player.getExoPlayer().getShuffleModeEnabled()); - - // Set repeat button to the correct UI state - setRepeatButton(player.getExoPlayer().getRepeatMode()); - - } - - public abstract void removeViewFromParent(); - - @Override - public void destroyPlayer() { - super.destroyPlayer(); - clearVideoSurface(); - } - - @Override - public void destroy() { - super.destroy(); - binding.endScreen.setImageDrawable(null); - deinitPlayerSeekOverlay(); - deinitListeners(); - } - - protected void setupElementsVisibility() { - setMuteButton(player.isMuted()); - animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0); - } - - protected abstract void setupElementsSize(Resources resources); - - protected void setupElementsSize(final int buttonsMinWidth, - final int playerTopPad, - final int controlsPad, - final int buttonsPad) { - binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0); - binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0); - binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - binding.audioTrackTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - binding.playbackSpeed.setMinimumWidth(buttonsMinWidth); - binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Broadcast receiver - //////////////////////////////////////////////////////////////////////////*/ - //region Broadcast receiver - - @Override - public void onBroadcastReceived(final Intent intent) { - super.onBroadcastReceived(intent); - if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { - // When the orientation changes, the screen height might be smaller. If the end screen - // thumbnail is not re-scaled, it can be larger than the current screen height and thus - // enlarging the whole player. This causes the seekbar to be out of the visible area. - updateEndScreenThumbnail(player.getThumbnail()); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Thumbnail - //////////////////////////////////////////////////////////////////////////*/ - //region Thumbnail - - /** - * Scale the player audio / end screen thumbnail down if necessary. - *

- * This is necessary when the thumbnail's height is larger than the device's height - * and thus is enlarging the player's height - * causing the bottom playback controls to be out of the visible screen. - *

- */ - @Override - public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - super.onThumbnailLoaded(bitmap); - updateEndScreenThumbnail(bitmap); - } - - private void updateEndScreenThumbnail(@Nullable final Bitmap thumbnail) { - if (thumbnail == null) { - // remove end screen thumbnail - binding.endScreen.setImageDrawable(null); - return; - } - - final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail); - final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap( - thumbnail, - (int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)), - (int) endScreenHeight, - null, - true); - - if (DEBUG) { - Log.d(TAG, "Thumbnail - onThumbnailLoaded() called with: " - + "currentThumbnail = [" + thumbnail + "], " - + thumbnail.getWidth() + "x" + thumbnail.getHeight() - + ", scaled end screen height = " + endScreenHeight - + ", scaled end screen width = " + endScreenBitmap.getWidth()); - } - - binding.endScreen.setImageBitmap(endScreenBitmap); - } - - protected abstract float calculateMaxEndScreenThumbnailHeight(@NonNull Bitmap bitmap); - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Progress loop and updates - //////////////////////////////////////////////////////////////////////////*/ - //region Progress loop and updates - - @Override - public void onUpdateProgress(final int currentProgress, - final int duration, - final int bufferPercent) { - - if (duration != binding.playbackSeekBar.getMax()) { - setVideoDurationToControls(duration); - } - if (player.getCurrentState() != STATE_PAUSED) { - updatePlayBackElementsCurrentDuration(currentProgress); - } - if (player.isLoading() || bufferPercent > 90) { - binding.playbackSeekBar.setSecondaryProgress( - (int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100))); - } - if (DEBUG && bufferPercent % 20 == 0) { //Limit log - Log.d(TAG, "notifyProgressUpdateToListeners() called with: " - + "isVisible = " + isControlsVisible() + ", " - + "currentProgress = [" + currentProgress + "], " - + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); - } - binding.playbackLiveSync.setClickable(!player.isLiveEdge()); - } - - /** - * Sets the current duration into the corresponding elements. - * - * @param currentProgress the current progress, in milliseconds - */ - private void updatePlayBackElementsCurrentDuration(final int currentProgress) { - // Don't set seekbar progress while user is seeking - if (player.getCurrentState() != STATE_PAUSED_SEEK) { - binding.playbackSeekBar.setProgress(currentProgress); - } - binding.playbackCurrentTime.setText(getTimeString(currentProgress)); - } - - /** - * Sets the video duration time into all control components (e.g. seekbar). - * - * @param duration the video duration, in milliseconds - */ - private void setVideoDurationToControls(final int duration) { - binding.playbackEndTime.setText(getTimeString(duration)); - - binding.playbackSeekBar.setMax(duration); - // This is important for Android TVs otherwise it would apply the default from - // setMax/Min methods which is (max - min) / 20 - binding.playbackSeekBar.setKeyProgressIncrement( - PlayerHelper.retrieveSeekDurationFromPreferences(player)); - } - - @Override // seekbar listener - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - // Currently we don't need method execution when fromUser is false - if (!fromUser) { - return; - } - if (DEBUG) { - Log.d(TAG, "onProgressChanged() called with: " - + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); - } - - binding.currentDisplaySeek.setText(getTimeString(progress)); - - // Seekbar Preview Thumbnail - SeekbarPreviewThumbnailHelper - .tryResizeAndSetSeekbarPreviewThumbnail( - player.getContext(), - seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), - binding.currentSeekbarPreviewThumbnail, - binding.subtitleView::getWidth); - - adjustSeekbarPreviewContainer(); - } - - - private void adjustSeekbarPreviewContainer() { - try { - // Should only be required when an error occurred before - // and the layout was positioned in the center - binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY); - - // Calculate the current left position of seekbar progress in px - // More info: https://stackoverflow.com/q/20493577 - final int currentSeekbarLeft = - binding.playbackSeekBar.getLeft() - + binding.playbackSeekBar.getPaddingLeft() - + binding.playbackSeekBar.getThumb().getBounds().left; - - // Calculate the (unchecked) left position of the container - final int uncheckedContainerLeft = - currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2); - - // Fix the position so it's within the boundaries - final int checkedContainerLeft = MathUtils.clamp(uncheckedContainerLeft, - 0, binding.playbackWindowRoot.getWidth() - - binding.seekbarPreviewContainer.getWidth()); - - // See also: https://stackoverflow.com/a/23249734 - final LinearLayout.LayoutParams params = - new LinearLayout.LayoutParams( - binding.seekbarPreviewContainer.getLayoutParams()); - params.setMarginStart(checkedContainerLeft); - binding.seekbarPreviewContainer.setLayoutParams(params); - } catch (final Exception ex) { - Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex); - // Fallback - position in the middle - binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER); - } - } - - @Override // seekbar listener - public void onStartTrackingTouch(final SeekBar seekBar) { - if (DEBUG) { - Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); - } - if (player.getCurrentState() != STATE_PAUSED_SEEK) { - player.changeState(STATE_PAUSED_SEEK); - } - - showControls(0); - animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SCALE_AND_ALPHA); - animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SCALE_AND_ALPHA); - } - - @Override // seekbar listener - public void onStopTrackingTouch(final SeekBar seekBar) { - if (DEBUG) { - Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); - } - - player.seekTo(seekBar.getProgress()); - if (player.getExoPlayer().getDuration() == seekBar.getProgress()) { - player.getExoPlayer().play(); - } - - binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA); - - if (player.getCurrentState() == STATE_PAUSED_SEEK) { - player.changeState(STATE_BUFFERING); - } - if (!player.isProgressLoopRunning()) { - player.startProgressLoop(); - } - - showControlsThenHide(); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Controls showing / hiding - //////////////////////////////////////////////////////////////////////////*/ - //region Controls showing / hiding - - public boolean isControlsVisible() { - return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; - } - - public void showControlsThenHide() { - if (DEBUG) { - Log.d(TAG, "showControlsThenHide() called"); - } - - showOrHideButtons(); - showSystemUIPartially(); - - final long hideTime = binding.playbackControlRoot.isInTouchMode() - ? DEFAULT_CONTROLS_HIDE_TIME - : DPAD_CONTROLS_HIDE_TIME; - - showHideShadow(true, DEFAULT_CONTROLS_DURATION); - animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, - AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); - } - - public void showControls(final long duration) { - if (DEBUG) { - Log.d(TAG, "showControls() called"); - } - showOrHideButtons(); - showSystemUIPartially(); - controlsVisibilityHandler.removeCallbacksAndMessages(null); - showHideShadow(true, duration); - animate(binding.playbackControlRoot, true, duration); - } - - public void hideControls(final long duration, final long delay) { - if (DEBUG) { - Log.d(TAG, "hideControls() called with: duration = [" + duration - + "], delay = [" + delay + "]"); - } - - showOrHideButtons(); - - controlsVisibilityHandler.removeCallbacksAndMessages(null); - controlsVisibilityHandler.postDelayed(() -> { - showHideShadow(false, duration); - animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA, - 0, this::hideSystemUIIfNeeded); - }, delay); - } - - public void showHideShadow(final boolean show, final long duration) { - animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null); - animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null); - animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null); - } - - protected void showOrHideButtons() { - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue == null) { - return; - } - - final boolean showPrev = playQueue.getIndex() != 0; - final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); - - binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); - binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); - binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE); - binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); - } - - protected void showSystemUIPartially() { - // system UI is really changed only by MainPlayerUi, so overridden there - } - - protected void hideSystemUIIfNeeded() { - // system UI is really changed only by MainPlayerUi, so overridden there - } - - protected boolean isAnyListViewOpen() { - // only MainPlayerUi has list views for the queue and for segments, so overridden there - return false; - } - - public boolean isFullscreen() { - // only MainPlayerUi can be in fullscreen, so overridden there - return false; - } - - /** - * Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action - * that will be performed when the button is clicked.. - * @param action the action that is performed when the play/pause button is clicked - */ - private void updatePlayPauseButton(final PlayButtonAction action) { - final AppCompatImageButton button = binding.playPauseButton; - switch (action) { - case PLAY: - button.setContentDescription(context.getString(R.string.play)); - button.setImageResource(R.drawable.ic_play_arrow); - break; - case PAUSE: - button.setContentDescription(context.getString(R.string.pause)); - button.setImageResource(R.drawable.ic_pause); - break; - case REPLAY: - button.setContentDescription(context.getString(R.string.replay)); - button.setImageResource(R.drawable.ic_replay); - break; - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Playback states - //////////////////////////////////////////////////////////////////////////*/ - //region Playback states - - @Override - public void onPrepared() { - super.onPrepared(); - setVideoDurationToControls((int) player.getExoPlayer().getDuration()); - binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); - } - - @Override - public void onBlocked() { - super.onBlocked(); - - // if we are e.g. switching players, hide controls - hideControls(DEFAULT_CONTROLS_DURATION, 0); - - binding.playbackSeekBar.setEnabled(false); - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - - binding.loadingPanel.setBackgroundColor(Color.BLACK); - animate(binding.loadingPanel, true, 0); - animate(binding.surfaceForeground, true, 100); - - updatePlayPauseButton(PlayButtonAction.PLAY); - animatePlayButtons(false, 100); - binding.getRoot().setKeepScreenOn(false); - } - - @Override - public void onPlaying() { - super.onPlaying(); - - updateStreamRelatedViews(); - - binding.playbackSeekBar.setEnabled(true); - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - - binding.loadingPanel.setVisibility(View.GONE); - - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - - animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, - () -> { - updatePlayPauseButton(PlayButtonAction.PAUSE); - animatePlayButtons(true, 200); - if (!isAnyListViewOpen()) { - binding.playPauseButton.requestFocus(); - } - }); - - binding.getRoot().setKeepScreenOn(true); - } - - @Override - public void onBuffering() { - super.onBuffering(); - binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); - binding.loadingPanel.setVisibility(View.VISIBLE); - binding.getRoot().setKeepScreenOn(true); - } - - @Override - public void onPaused() { - super.onPaused(); - - // Don't let UI elements popup during double tap seeking. This state is entered sometimes - // during seeking/loading. This if-else check ensures that the controls aren't popping up. - if (!playerGestureListener.isDoubleTapping()) { - showControls(400); - binding.loadingPanel.setVisibility(View.GONE); - - animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, - () -> { - updatePlayPauseButton(PlayButtonAction.PLAY); - animatePlayButtons(true, 200); - if (!isAnyListViewOpen()) { - binding.playPauseButton.requestFocus(); - } - }); - } - - binding.getRoot().setKeepScreenOn(false); - } - - @Override - public void onPausedSeek() { - super.onPausedSeek(); - animatePlayButtons(false, 100); - binding.getRoot().setKeepScreenOn(true); - } - - @Override - public void onCompleted() { - super.onCompleted(); - - animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, - () -> { - updatePlayPauseButton(PlayButtonAction.REPLAY); - animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); - }); - - binding.getRoot().setKeepScreenOn(false); - - // When a (short) video ends the elements have to display the correct values - see #6180 - updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax()); - - showControls(500); - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - binding.loadingPanel.setVisibility(View.GONE); - animate(binding.surfaceForeground, true, 100); - } - - private void animatePlayButtons(final boolean show, final long duration) { - animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA); - - @Nullable final PlayQueue playQueue = player.getPlayQueue(); - if (playQueue == null) { - return; - } - - if (!show || playQueue.getIndex() > 0) { - animate( - binding.playPreviousButton, - show, - duration, - AnimationType.SCALE_AND_ALPHA); - } - if (!show || playQueue.getIndex() + 1 < playQueue.getStreams().size()) { - animate( - binding.playNextButton, - show, - duration, - AnimationType.SCALE_AND_ALPHA); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Repeat, shuffle, mute - //////////////////////////////////////////////////////////////////////////*/ - //region Repeat, shuffle, mute - - public void onRepeatClicked() { - if (DEBUG) { - Log.d(TAG, "onRepeatClicked() called"); - } - player.cycleNextRepeatMode(); - } - - public void onShuffleClicked() { - if (DEBUG) { - Log.d(TAG, "onShuffleClicked() called"); - } - player.toggleShuffleModeEnabled(); - } - - @Override - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - super.onRepeatModeChanged(repeatMode); - - if (repeatMode == REPEAT_MODE_ALL) { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all); - } else if (repeatMode == REPEAT_MODE_ONE) { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one); - } else /* repeatMode == REPEAT_MODE_OFF */ { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off); - } - } - - @Override - public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { - super.onShuffleModeEnabledChanged(shuffleModeEnabled); - setShuffleButton(shuffleModeEnabled); - } - - @Override - public void onMuteUnmuteChanged(final boolean isMuted) { - super.onMuteUnmuteChanged(isMuted); - setMuteButton(isMuted); - } - - private void setMuteButton(final boolean isMuted) { - binding.switchMute.setImageDrawable(AppCompatResources.getDrawable(context, isMuted - ? R.drawable.ic_volume_off : R.drawable.ic_volume_up)); - } - - private void setShuffleButton(final boolean shuffled) { - binding.shuffleButton.setImageAlpha(shuffled ? 255 : 77); - } - - private void setRepeatButton(final int repeatMode) { - final int resId = switch (repeatMode) { - case REPEAT_MODE_ALL - -> com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all; - case REPEAT_MODE_ONE - -> com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one; - default -> com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off; - }; - binding.repeatButton.setImageResource(resId); - } - - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Other player listeners - //////////////////////////////////////////////////////////////////////////*/ - //region Other player listeners - - @Override - public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { - super.onPlaybackParametersChanged(playbackParameters); - binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed)); - } - - @Override - public void onRenderedFirstFrame() { - super.onRenderedFirstFrame(); - //TODO check if this causes black screen when switching to fullscreen - animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Metadata & stream related views - //////////////////////////////////////////////////////////////////////////*/ - //region Metadata & stream related views - - @Override - public void onMetadataChanged(@NonNull final StreamInfo info) { - super.onMetadataChanged(info); - - updateStreamRelatedViews(); - - binding.titleTextView.setText(info.getName()); - binding.channelTextView.setText(info.getUploaderName()); - - this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()); - } - - private void updateStreamRelatedViews() { - player.getCurrentStreamInfo().ifPresent(info -> { - binding.qualityTextView.setVisibility(View.GONE); - binding.audioTrackTextView.setVisibility(View.GONE); - binding.playbackSpeed.setVisibility(View.GONE); - - binding.playbackEndTime.setVisibility(View.GONE); - binding.playbackLiveSync.setVisibility(View.GONE); - - switch (info.getStreamType()) { - case AUDIO_STREAM: - case POST_LIVE_AUDIO_STREAM: - binding.surfaceView.setVisibility(View.GONE); - binding.endScreen.setVisibility(View.VISIBLE); - binding.playbackEndTime.setVisibility(View.VISIBLE); - break; - - case AUDIO_LIVE_STREAM: - binding.surfaceView.setVisibility(View.GONE); - binding.endScreen.setVisibility(View.VISIBLE); - binding.playbackLiveSync.setVisibility(View.VISIBLE); - break; - - case LIVE_STREAM: - binding.surfaceView.setVisibility(View.VISIBLE); - binding.endScreen.setVisibility(View.GONE); - binding.playbackLiveSync.setVisibility(View.VISIBLE); - break; - - case VIDEO_STREAM: - case POST_LIVE_STREAM: - if (player.getCurrentMetadata() != null - && player.getCurrentMetadata().getMaybeQuality().isEmpty() - || (info.getVideoStreams().isEmpty() - && info.getVideoOnlyStreams().isEmpty())) { - break; - } - - buildQualityMenu(); - buildAudioTrackMenu(); - - binding.qualityTextView.setVisibility(View.VISIBLE); - binding.surfaceView.setVisibility(View.VISIBLE); - // fallthrough - default: - binding.endScreen.setVisibility(View.GONE); - binding.playbackEndTime.setVisibility(View.VISIBLE); - break; - } - - buildPlaybackSpeedMenu(); - binding.playbackSpeed.setVisibility(View.VISIBLE); - }); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Popup menus ("popup" means that they pop up, not that they belong to the popup player) - //////////////////////////////////////////////////////////////////////////*/ - //region Popup menus ("popup" means that they pop up, not that they belong to the popup player) - - private void buildQualityMenu() { - if (qualityPopupMenu == null) { - return; - } - qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY); - - final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) - .flatMap(MediaItemTag::getMaybeQuality) - .map(MediaItemTag.Quality::getSortedVideoStreams) - .orElse(null); - if (availableStreams == null) { - return; - } - - for (int i = 0; i < availableStreams.size(); i++) { - final VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat - .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); - } - qualityPopupMenu.setOnMenuItemClickListener(this); - qualityPopupMenu.setOnDismissListener(this); - - player.getSelectedVideoStream() - .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); - } - - private void buildAudioTrackMenu() { - if (audioTrackPopupMenu == null) { - return; - } - audioTrackPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK); - - final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) - .flatMap(MediaItemTag::getMaybeAudioTrack) - .map(MediaItemTag.AudioTrack::getAudioStreams) - .orElse(null); - if (availableStreams == null || availableStreams.size() < 2) { - return; - } - - for (int i = 0; i < availableStreams.size(); i++) { - final AudioStream audioStream = availableStreams.get(i); - audioTrackPopupMenu.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE, - Localization.audioTrackName(context, audioStream)); - } - - player.getSelectedAudioStream() - .ifPresent(s -> binding.audioTrackTextView.setText( - Localization.audioTrackName(context, s))); - binding.audioTrackTextView.setVisibility(View.VISIBLE); - audioTrackPopupMenu.setOnMenuItemClickListener(this); - audioTrackPopupMenu.setOnDismissListener(this); - } - - private void buildPlaybackSpeedMenu() { - if (playbackSpeedPopupMenu == null) { - return; - } - playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED); - - for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { - playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, - formatSpeed(PLAYBACK_SPEEDS[i])); - } - binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); - playbackSpeedPopupMenu.setOnMenuItemClickListener(this); - playbackSpeedPopupMenu.setOnDismissListener(this); - } - - private void buildCaptionMenu(@NonNull final List availableLanguages) { - if (captionPopupMenu == null) { - return; - } - captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION); - - captionPopupMenu.setOnDismissListener(this); - - // Add option for turning off caption - final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, - 0, Menu.NONE, R.string.caption_none); - captionOffItem.setOnMenuItemClickListener(menuItem -> { - final int textRendererIndex = player.getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - player.getTrackSelector().setParameters(player.getTrackSelector() - .buildUponParameters().setRendererDisabled(textRendererIndex, true)); - } - player.getPrefs().edit() - .remove(context.getString(R.string.caption_user_set_key)).apply(); - return true; - }); - - // Add all available captions - for (int i = 0; i < availableLanguages.size(); i++) { - final String captionLanguage = availableLanguages.get(i); - final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, - i + 1, Menu.NONE, captionLanguage); - captionItem.setOnMenuItemClickListener(menuItem -> { - final int textRendererIndex = player.getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - // DefaultTrackSelector will select for text tracks in the following order. - // When multiple tracks share the same rank, a random track will be chosen. - // 1. ANY track exactly matching preferred language name - // 2. ANY track exactly matching preferred language stem - // 3. ROLE_FLAG_CAPTION track matching preferred language stem - // 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem - // This means if a caption track of preferred language is not available, - // then an auto-generated track of that language will be chosen automatically. - player.getTrackSelector().setParameters(player.getTrackSelector() - .buildUponParameters() - .setPreferredTextLanguages(captionLanguage, - PlayerHelper.captionLanguageStemOf(captionLanguage)) - .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) - .setRendererDisabled(textRendererIndex, false)); - player.getPrefs().edit().putString(context.getString( - R.string.caption_user_set_key), captionLanguage).apply(); - } - return true; - }); - } - captionPopupMenu.setOnDismissListener(this); - - // apply caption language from previous user preference - final int textRendererIndex = player.getCaptionRendererIndex(); - if (textRendererIndex == RENDERER_UNAVAILABLE) { - return; - } - - // If user prefers to show no caption, then disable the renderer. - // Otherwise, DefaultTrackSelector may automatically find an available caption - // and display that. - final String userPreferredLanguage = - player.getPrefs().getString(context.getString(R.string.caption_user_set_key), null); - if (userPreferredLanguage == null) { - player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() - .setRendererDisabled(textRendererIndex, true)); - return; - } - - // Only set preferred language if it does not match the user preference, - // otherwise there might be an infinite cycle at onTextTracksChanged. - final List selectedPreferredLanguages = - player.getTrackSelector().getParameters().preferredTextLanguages; - if (!selectedPreferredLanguages.contains(userPreferredLanguage)) { - player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() - .setPreferredTextLanguages(userPreferredLanguage, - PlayerHelper.captionLanguageStemOf(userPreferredLanguage)) - .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) - .setRendererDisabled(textRendererIndex, false)); - } - } - - protected abstract void onPlaybackSpeedClicked(); - - private void onQualityClicked() { - qualityPopupMenu.show(); - isSomePopupMenuVisible = true; - - player.getSelectedVideoStream() - .map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution()) - .ifPresent(binding.qualityTextView::setText); - } - - private void onAudioTracksClicked() { - audioTrackPopupMenu.show(); - isSomePopupMenuVisible = true; - } - - /** - * Called when an item of the quality selector or the playback speed selector is selected. - */ - @Override - public boolean onMenuItemClick(@NonNull final MenuItem menuItem) { - if (DEBUG) { - Log.d(TAG, "onMenuItemClick() called with: " - + "menuItem = [" + menuItem + "], " - + "menuItem.getItemId = [" + menuItem.getItemId() + "]"); - } - - if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) { - onQualityItemClick(menuItem); - return true; - } else if (menuItem.getGroupId() == POPUP_MENU_ID_AUDIO_TRACK) { - onAudioTrackItemClick(menuItem); - return true; - } else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) { - final int speedIndex = menuItem.getItemId(); - final float speed = PLAYBACK_SPEEDS[speedIndex]; - - player.setPlaybackSpeed(speed); - binding.playbackSpeed.setText(formatSpeed(speed)); - } - - return false; - } - - private void onQualityItemClick(@NonNull final MenuItem menuItem) { - final int menuItemIndex = menuItem.getItemId(); - @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); - if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { - return; - } - - final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get(); - final List availableStreams = quality.getSortedVideoStreams(); - final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); - if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { - return; - } - - final String newResolution = availableStreams.get(menuItemIndex).getResolution(); - player.setPlaybackQuality(newResolution); - - binding.qualityTextView.setText(menuItem.getTitle()); - } - - private void onAudioTrackItemClick(@NonNull final MenuItem menuItem) { - final int menuItemIndex = menuItem.getItemId(); - @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); - if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) { - return; - } - - final MediaItemTag.AudioTrack audioTrack = - currentMetadata.getMaybeAudioTrack().get(); - final List availableStreams = audioTrack.getAudioStreams(); - final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); - if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { - return; - } - - final String newAudioTrack = availableStreams.get(menuItemIndex).getAudioTrackId(); - player.setAudioTrack(newAudioTrack); - - binding.audioTrackTextView.setText(menuItem.getTitle()); - } - - /** - * Called when some popup menu is dismissed. - */ - @Override - public void onDismiss(@Nullable final PopupMenu menu) { - if (DEBUG) { - Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); - } - isSomePopupMenuVisible = false; //TODO check if this works - player.getSelectedVideoStream() - .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); - - if (player.isPlaying()) { - hideControls(DEFAULT_CONTROLS_DURATION, 0); - hideSystemUIIfNeeded(); - } - } - - private void onCaptionClicked() { - if (DEBUG) { - Log.d(TAG, "onCaptionClicked() called"); - } - captionPopupMenu.show(); - isSomePopupMenuVisible = true; - } - - public boolean isSomePopupMenuVisible() { - return isSomePopupMenuVisible; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Captions (text tracks) - //////////////////////////////////////////////////////////////////////////*/ - //region Captions (text tracks) - - @Override - public void onTextTracksChanged(@NonNull final Tracks currentTracks) { - super.onTextTracksChanged(currentTracks); - - final boolean trackTypeTextSupported = !currentTracks.containsType(C.TRACK_TYPE_TEXT) - || currentTracks.isTypeSupported(C.TRACK_TYPE_TEXT, false); - if (getPlayer().getTrackSelector().getCurrentMappedTrackInfo() == null - || !trackTypeTextSupported) { - binding.captionTextView.setVisibility(View.GONE); - return; - } - - // Extract all loaded languages - final List textTracks = currentTracks - .getGroups() - .stream() - .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType()) - .collect(Collectors.toList()); - final List availableLanguages = textTracks.stream() - .map(Tracks.Group::getMediaTrackGroup) - .filter(textTrack -> textTrack.length > 0) - .map(textTrack -> textTrack.getFormat(0).language) - .collect(Collectors.toList()); - - // Find selected text track - final Optional selectedTracks = textTracks.stream() - .filter(Tracks.Group::isSelected) - .filter(info -> info.getMediaTrackGroup().length >= 1) - .map(info -> info.getMediaTrackGroup().getFormat(0)) - .findFirst(); - - // Build UI - buildCaptionMenu(availableLanguages); - if (player.getTrackSelector().getParameters().getRendererDisabled( - player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) { - binding.captionTextView.setText(R.string.caption_none); - } else { - binding.captionTextView.setText(selectedTracks.get().language); - } - binding.captionTextView.setVisibility( - availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); - } - - @Override - public void onCues(@NonNull final List cues) { - super.onCues(cues); - binding.subtitleView.setCues(cues); - } - - private void setupSubtitleView() { - setupSubtitleView(PlayerHelper.getCaptionScale(context)); - final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context); - binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT); - binding.subtitleView.setStyle(captionStyle); - } - - /** - * - * @param captionScale Value returned by {@link PlayerHelper#getCaptionScale}. - */ - protected abstract void setupSubtitleView(float captionScale); - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Click listeners - //////////////////////////////////////////////////////////////////////////*/ - //region Click listeners - - /** - * Create on-click listener which manages the player controls after the view on-click action. - * - * @param runnable The action to be executed. - * @return The view click listener. - */ - protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) { - return v -> { - if (DEBUG) { - Log.d(TAG, "onClick() called with: v = [" + v + "]"); - } - - runnable.run(); - - // Manages the player controls after handling the view click. - if (player.getCurrentState() == STATE_COMPLETED) { - return; - } - controlsVisibilityHandler.removeCallbacksAndMessages(null); - showHideShadow(true, DEFAULT_CONTROLS_DURATION); - animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, - AnimationType.ALPHA, 0, () -> { - if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { - if (v == binding.playPauseButton - // Hide controls in fullscreen immediately - || (v == binding.screenRotationButton && isFullscreen())) { - hideControls(0, 0); - } else { - hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); - } - } - }); - }; - } - - public boolean onKeyDown(final int keyCode) { - switch (keyCode) { - case KeyEvent.KEYCODE_BACK: - if (DeviceUtils.isTv(context) && isControlsVisible()) { - hideControls(0, 0); - return true; - } - break; - case KeyEvent.KEYCODE_DPAD_UP: - case KeyEvent.KEYCODE_DPAD_LEFT: - case KeyEvent.KEYCODE_DPAD_DOWN: - case KeyEvent.KEYCODE_DPAD_RIGHT: - case KeyEvent.KEYCODE_DPAD_CENTER: - if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) - || isAnyListViewOpen()) { - // do not interfere with focus in playlist and play queue etc. - break; - } - - if (player.getCurrentState() == org.schabi.newpipe.player.Player.STATE_BLOCKED) { - return true; - } - - if (isControlsVisible()) { - hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); - } else { - binding.playPauseButton.requestFocus(); - showControlsThenHide(); - showSystemUIPartially(); - return true; - } - break; - default: - break; // ignore other keys - } - - return false; - } - - private void onMoreOptionsClicked() { - if (DEBUG) { - Log.d(TAG, "onMoreOptionsClicked() called"); - } - - final boolean isMoreControlsVisible = - binding.secondaryControls.getVisibility() == View.VISIBLE; - - animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, - isMoreControlsVisible ? 0 : 180); - animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA, 0, () -> { - // Fix for a ripple effect on background drawable. - // When view returns from GONE state it takes more milliseconds than returning - // from INVISIBLE state. And the delay makes ripple background end to fast - if (isMoreControlsVisible) { - binding.secondaryControls.setVisibility(View.INVISIBLE); - } - }); - showControls(DEFAULT_CONTROLS_DURATION); - } - - private void onPlayWithKodiClicked() { - if (player.getCurrentMetadata() != null) { - player.pause(); - KoreUtils.playWithKore(context, Uri.parse(player.getVideoUrl())); - } - } - - private void onOpenInBrowserClicked() { - player.getCurrentStreamInfo().ifPresent(streamInfo -> - ShareUtils.openUrlInBrowser(player.getContext(), streamInfo.getOriginalUrl())); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Video size - //////////////////////////////////////////////////////////////////////////*/ - //region Video size - - protected void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { - binding.surfaceView.setResizeMode(resizeMode); - binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode)); - } - - void onResizeClicked() { - setResizeMode(nextResizeModeAndSaveToPrefs(player, binding.surfaceView.getResizeMode())); - } - - @Override - public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { - super.onVideoSizeChanged(videoSize); - // Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0 - // if the renderer is disabled. In that case, we skip updating the aspect ratio. - if (videoSize.width == 0 || videoSize.height == 0) { - return; - } - binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // SurfaceHolderCallback helpers - //////////////////////////////////////////////////////////////////////////*/ - //region SurfaceHolderCallback helpers - - /** - * Connects the video surface to the exo player. This can be called anytime without the risk for - * issues to occur, since the player will run just fine when no surface is connected. Therefore - * the video surface will be setup only when all of these conditions are true: it is not already - * setup (this just prevents wasting resources to setup the surface again), there is an exo - * player, the root view is attached to a parent and the surface view is valid/unreleased (the - * latter two conditions prevent "The surface has been released" errors). So this function can - * be called many times and even while the UI is in unready states. - */ - public void setupVideoSurfaceIfNeeded() { - if (!surfaceIsSetup && player.getExoPlayer() != null - && binding.getRoot().getParent() != null) { - // make sure there is nothing left over from previous calls - clearVideoSurface(); - - surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer()); - binding.surfaceView.getHolder().addCallback(surfaceHolderCallback); - - // ensure player is using an unreleased surface, which the surfaceView might not be - // when starting playback on background or during player switching - if (binding.surfaceView.getHolder().getSurface().isValid()) { - // initially set the surface manually otherwise - // onRenderedFirstFrame() will not be called - player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder()); - } - - surfaceIsSetup = true; - } - } - - private void clearVideoSurface() { - if (surfaceHolderCallback != null) { - binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); - surfaceHolderCallback.release(); - surfaceHolderCallback = null; - } - Optional.ofNullable(player.getExoPlayer()).ifPresent(ExoPlayer::clearVideoSurface); - surfaceIsSetup = false; - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Getters - //////////////////////////////////////////////////////////////////////////*/ - //region Getters - - public PlayerBinding getBinding() { - return binding; - } - - public GestureDetector getGestureDetector() { - return gestureDetector; - } - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java deleted file mode 100644 index ef0e8670c..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.os.Bundle; -import android.provider.Settings; -import android.widget.Toast; - -import androidx.core.app.ActivityCompat; -import androidx.preference.Preference; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ThemeHelper; - -public class AppearanceSettingsFragment extends BasePreferenceFragment { - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - final String themeKey = getString(R.string.theme_key); - // the key of the active theme when settings were opened (or recreated after theme change) - final String startThemeKey = defaultPreferences - .getString(themeKey, getString(R.string.default_theme_value)); - final String autoDeviceThemeKey = getString(R.string.auto_device_theme_key); - findPreference(themeKey).setOnPreferenceChangeListener((preference, newValue) -> { - if (newValue.toString().equals(autoDeviceThemeKey)) { - Toast.makeText(getContext(), getString(R.string.select_night_theme_toast), - Toast.LENGTH_LONG).show(); - } - - applyThemeChange(startThemeKey, themeKey, newValue); - return false; - }); - - final String nightThemeKey = getString(R.string.night_theme_key); - if (startThemeKey.equals(autoDeviceThemeKey)) { - final String startNightThemeKey = defaultPreferences - .getString(nightThemeKey, getString(R.string.default_night_theme_value)); - - findPreference(nightThemeKey).setOnPreferenceChangeListener((preference, newValue) -> { - applyThemeChange(startNightThemeKey, nightThemeKey, newValue); - return false; - }); - } else { - // disable the night theme selection - final Preference preference = findPreference(nightThemeKey); - if (preference != null) { - preference.setEnabled(false); - preference.setSummary(getString(R.string.night_theme_available, - getString(R.string.auto_device_theme_title))); - } - } - } - - @Override - public boolean onPreferenceTreeClick(final Preference preference) { - if (getString(R.string.caption_settings_key).equals(preference.getKey())) { - try { - startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS)); - } catch (final ActivityNotFoundException e) { - Toast.makeText(getActivity(), R.string.general_error, Toast.LENGTH_SHORT).show(); - } - } - - return super.onPreferenceTreeClick(preference); - } - - private void applyThemeChange(final String beginningThemeKey, - final String themeKey, - final Object newValue) { - defaultPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, true).apply(); - defaultPreferences.edit().putString(themeKey, newValue.toString()).apply(); - - ThemeHelper.setDayNightMode(requireContext(), newValue.toString()); - - if (!newValue.equals(beginningThemeKey) && getActivity() != null) { - // if it's not the current theme - ActivityCompat.recreate(getActivity()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java deleted file mode 100644 index 11c4daede..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java +++ /dev/null @@ -1,314 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.widget.Toast; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; - -import com.grack.nanojson.JsonParserException; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.subscription.SubscriptionsImportExportHelper; -import org.schabi.newpipe.settings.export.BackupFileLocator; -import org.schabi.newpipe.settings.export.ImportExportManager; -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ZipHelper; - -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class BackupRestoreSettingsFragment extends BasePreferenceFragment { - - private static final String ZIP_MIME_TYPE = "application/zip"; - - private final SimpleDateFormat exportDateFormat = - new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); - private ImportExportManager manager; - private String importExportDataPathKey; - private final ActivityResultLauncher requestImportPathLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), - this::requestImportPathResult); - private final ActivityResultLauncher requestExportPathLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), - this::requestExportPathResult); - private SubscriptionsImportExportHelper importExportHelper; - - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - importExportHelper = new SubscriptionsImportExportHelper(this); - } - - @Override - public void onCreatePreferences(@Nullable final Bundle savedInstanceState, - @Nullable final String rootKey) { - manager = new ImportExportManager(new BackupFileLocator(requireContext())); - - importExportDataPathKey = getString(R.string.import_export_data_path); - - addPreferencesFromResourceRegistry(); - - final Preference importDataPreference = requirePreference(R.string.import_data); - importDataPreference.setOnPreferenceClickListener((Preference p) -> { - NoFileManagerSafeGuard.launchSafe( - requestImportPathLauncher, - StoredFileHelper.getPicker(requireContext(), - ZIP_MIME_TYPE, getImportExportDataUri()), - TAG, - getContext() - ); - - return true; - }); - - final Preference exportDataPreference = requirePreference(R.string.export_data); - exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { - NoFileManagerSafeGuard.launchSafe( - requestExportPathLauncher, - StoredFileHelper.getNewPicker(requireContext(), - "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip", - ZIP_MIME_TYPE, getImportExportDataUri()), - TAG, - getContext() - ); - - return true; - }); - - final Preference resetSettings = requirePreference(R.string.reset_settings); - // Resets all settings by deleting shared preference and restarting the app - // A dialogue will pop up to confirm if user intends to reset all settings - resetSettings.setOnPreferenceClickListener(preference -> { - // Show Alert Dialogue - final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setMessage(R.string.reset_all_settings); - builder.setCancelable(true); - builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> { - // Deletes all shared preferences xml files. - final SharedPreferences sharedPreferences = - PreferenceManager.getDefaultSharedPreferences(requireContext()); - sharedPreferences.edit().clear().apply(); - // Restarts the app - if (getActivity() == null) { - return; - } - NavigationHelper.restartApp(getActivity()); - }); - builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> { - }); - final AlertDialog alertDialog = builder.create(); - alertDialog.show(); - return true; - }); - - final Preference exportSubsPreference = - requirePreference(R.string.export_subscriptions_key); - exportSubsPreference.setOnPreferenceClickListener(reference -> { - importExportHelper.onExportSelected(); - return true; - }); - - final Preference importSubsPreference = - requirePreference(R.string.import_subscriptions_key); - importSubsPreference.setOnPreferenceClickListener(preference -> { - importExportHelper.onImportPreviousSelected(); - return true; - }); - - } - - private void requestExportPathResult(final ActivityResult result) { - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - // will be saved only on success - final Uri lastExportDataUri = result.getData().getData(); - - final StoredFileHelper file = new StoredFileHelper( - requireContext(), result.getData().getData(), ZIP_MIME_TYPE); - - exportDatabase(file, lastExportDataUri); - } - } - - private void requestImportPathResult(final ActivityResult result) { - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - // will be saved only on success - final Uri lastImportDataUri = result.getData().getData(); - - final StoredFileHelper file = new StoredFileHelper( - requireContext(), result.getData().getData(), ZIP_MIME_TYPE); - - new androidx.appcompat.app.AlertDialog.Builder(requireActivity()) - .setMessage(R.string.override_current_data) - .setPositiveButton(R.string.ok, (d, id) -> - importDatabase(file, lastImportDataUri)) - .setNegativeButton(R.string.cancel, (d, id) -> - d.cancel()) - .show(); - } - } - - private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) { - try (ExecutorService executor = Executors.newSingleThreadExecutor()) { - //checkpoint before export - executor.submit(NewPipeDatabase::checkpoint).get(); - - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - manager.exportDatabase(preferences, file); - - saveLastImportExportDataUri(exportDataUri); // save export path only on success - Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT) - .show(); - } catch (final Exception e) { - showErrorSnackbar(e, "Exporting database and settings"); - } - } - - private void importDatabase(final StoredFileHelper file, final Uri importDataUri) { - // check if file is supported - if (!ZipHelper.isValidZipFile(file)) { - Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) - .show(); - return; - } - - try { - manager.ensureDbDirectoryExists(); - - // replace the current database - if (!manager.extractDb(file)) { - Toast.makeText(requireContext(), R.string.could_not_import_all_files, - Toast.LENGTH_LONG) - .show(); - } - - // if settings file exist, ask if it should be imported. - final boolean hasJsonPrefs = manager.exportHasJsonPrefs(file); - if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) { - new androidx.appcompat.app.AlertDialog.Builder(requireContext()) - .setTitle(R.string.import_settings) - .setMessage(hasJsonPrefs ? null : requireContext() - .getString(R.string.import_settings_vulnerable_format)) - .setOnDismissListener(dialog -> finishImport(importDataUri)) - .setNegativeButton(R.string.cancel, (dialog, which) -> { - dialog.dismiss(); - finishImport(importDataUri); - }) - .setPositiveButton(R.string.ok, (dialog, which) -> { - dialog.dismiss(); - final Context context = requireContext(); - final SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(context); - try { - if (hasJsonPrefs) { - manager.loadJsonPrefs(file, prefs); - } else { - manager.loadSerializedPrefs(file, prefs); - } - } catch (IOException | ClassNotFoundException | JsonParserException e) { - createErrorNotification(e, "Importing preferences"); - return; - } - cleanImport(context, prefs); - finishImport(importDataUri); - }) - .show(); - } else { - finishImport(importDataUri); - } - } catch (final Exception e) { - showErrorSnackbar(e, "Importing database and settings"); - } - } - - /** - * Remove settings that are not supposed to be imported on different devices - * and reset them to default values. - * @param context the context used for the import - * @param prefs the preferences used while running the import - */ - private void cleanImport(@NonNull final Context context, - @NonNull final SharedPreferences prefs) { - // Check if media tunnelling needs to be disabled automatically, - // if it was disabled automatically in the imported preferences. - final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key); - final String automaticTunnelingKey = - context.getString(R.string.disabled_media_tunneling_automatically_key); - // R.string.disable_media_tunneling_key should always be true - // if R.string.disabled_media_tunneling_automatically_key equals 1, - // but we double check here just to be sure and to avoid regressions - // caused by possible later modification of the media tunneling functionality. - // R.string.disabled_media_tunneling_automatically_key == 0: - // automatic value overridden by user in settings - // R.string.disabled_media_tunneling_automatically_key == -1: not set - final boolean wasMediaTunnelingDisabledAutomatically = - prefs.getInt(automaticTunnelingKey, -1) == 1 - && prefs.getBoolean(tunnelingKey, false); - if (wasMediaTunnelingDisabledAutomatically) { - prefs.edit() - .putInt(automaticTunnelingKey, -1) - .putBoolean(tunnelingKey, false) - .apply(); - NewPipeSettings.setMediaTunneling(context); - } - } - - /** - * Save import path and restart app. - * - * @param importDataUri The import path to save - */ - private void finishImport(final Uri importDataUri) { - // save import path only on success - saveLastImportExportDataUri(importDataUri); - // restart app to properly load db - NavigationHelper.restartApp(requireActivity()); - } - - private Uri getImportExportDataUri() { - final String path = defaultPreferences.getString(importExportDataPathKey, null); - return isBlank(path) ? null : Uri.parse(path); - } - - private void saveLastImportExportDataUri(final Uri importExportDataUri) { - final SharedPreferences.Editor editor = defaultPreferences.edit() - .putString(importExportDataPathKey, importExportDataUri.toString()); - editor.apply(); - } - - private void showErrorSnackbar(final Throwable e, final String request) { - ErrorUtil.showSnackbar(this, new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)); - } - - private void createErrorNotification(final Throwable e, final String request) { - ErrorUtil.createNotification( - requireContext(), - new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request) - ); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java deleted file mode 100644 index 21cba3daa..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.Objects; - -public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { - protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); - protected static final boolean DEBUG = MainActivity.DEBUG; - - SharedPreferences defaultPreferences; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - defaultPreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); - super.onCreate(savedInstanceState); - } - - protected void addPreferencesFromResourceRegistry() { - addPreferencesFromResource( - SettingsResourceRegistry.getInstance().getPreferencesResId(this.getClass())); - } - - @Override - public void onViewCreated(@NonNull final View rootView, - @Nullable final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - setDivider(null); - ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); - } - - @Override - public void onResume() { - super.onResume(); - ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); - } - - @NonNull - public final T requirePreference(@StringRes final int resId) { - final T preference = findPreference(getString(resId)); - Objects.requireNonNull(preference); - return preference; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java deleted file mode 100644 index 85ee97853..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.util.Log; -import android.widget.Toast; - -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.os.LocaleListCompat; -import androidx.preference.Preference; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PreferredImageQuality; - -import java.util.Locale; - -import coil3.SingletonImageLoader; - -public class ContentSettingsFragment extends BasePreferenceFragment { - private String youtubeRestrictedModeEnabledKey; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - - addPreferencesFromResourceRegistry(); - - setupAppLanguagePreferences(); - setupImageQualityPref(); - } - - private void setupAppLanguagePreferences() { - final Preference appLanguagePref = requirePreference(R.string.app_language_key); - // Android 13+ allows to set app specific languages - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - appLanguagePref.setVisible(false); - - final Preference newAppLanguagePref = - requirePreference(R.string.app_language_android_13_and_up_key); - newAppLanguagePref.setSummaryProvider(preference -> { - final Locale loc = AppCompatDelegate.getApplicationLocales().get(0); - return loc != null ? loc.getDisplayName() : getString(R.string.systems_language); - }); - newAppLanguagePref.setOnPreferenceClickListener(preference -> { - final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS) - .setData(Uri.fromParts("package", requireContext().getPackageName(), null)); - startActivity(intent); - return true; - }); - newAppLanguagePref.setVisible(true); - return; - } - - appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> { - final String language = (String) newValue; - final String systemLang = getString(R.string.default_localization_key); - final String tag = systemLang.equals(language) ? null : language; - AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)); - return true; - }); - } - - private void setupImageQualityPref() { - requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener( - (preference, newValue) -> { - ImageStrategy.setPreferredImageQuality(PreferredImageQuality - .fromPreferenceKey(requireContext(), (String) newValue)); - final var loader = SingletonImageLoader.get(preference.getContext()); - loader.getMemoryCache().clear(); - loader.getDiskCache().clear(); - Toast.makeText(preference.getContext(), - R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) - .show(); - return true; - }); - } - - @Override - public boolean onPreferenceTreeClick(final Preference preference) { - if (preference.getKey().equals(youtubeRestrictedModeEnabledKey)) { - final Context context = getContext(); - if (context != null) { - DownloaderImpl.getInstance().updateYoutubeRestrictedModeCookies(context); - } else { - Log.w(TAG, "onPreferenceTreeClick: null context"); - } - } - - return super.onPreferenceTreeClick(preference); - } - - @Override - public void onDestroy() { - super.onDestroy(); - - final Context context = requireContext(); - NewPipe.setupLocalization( - Localization.getPreferredLocalization(context), - Localization.getPreferredContentCountry(context)); - PlayerHelper.resetFormat(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java deleted file mode 100644 index 229de7005..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Intent; -import android.os.Bundle; - -import androidx.preference.Preference; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.feed.notifications.NotificationWorker; - -import java.util.Optional; - -public class DebugSettingsFragment extends BasePreferenceFragment { - private static final String DUMMY = "Dummy"; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - final Preference allowHeapDumpingPreference = - requirePreference(R.string.allow_heap_dumping_key); - final Preference showMemoryLeaksPreference = - requirePreference(R.string.show_memory_leaks_key); - final Preference checkNewStreamsPreference = - requirePreference(R.string.check_new_streams_key); - final Preference crashTheAppPreference = - requirePreference(R.string.crash_the_app_key); - final Preference showErrorSnackbarPreference = - requirePreference(R.string.show_error_snackbar_key); - final Preference createErrorNotificationPreference = - requirePreference(R.string.create_error_notification_key); - - - final Optional optBVLeakCanary = getBVDLeakCanary(); - - allowHeapDumpingPreference.setEnabled(optBVLeakCanary.isPresent()); - showMemoryLeaksPreference.setEnabled(optBVLeakCanary.isPresent()); - - if (optBVLeakCanary.isPresent()) { - final DebugSettingsBVDLeakCanaryAPI pdLeakCanary = optBVLeakCanary.get(); - - showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> { - startActivity(pdLeakCanary.getNewLeakDisplayActivityIntent()); - return true; - }); - } else { - allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available); - showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available); - } - - checkNewStreamsPreference.setOnPreferenceClickListener(preference -> { - NotificationWorker.runNow(preference.getContext()); - return true; - }); - - crashTheAppPreference.setOnPreferenceClickListener(preference -> { - throw new RuntimeException(DUMMY); - }); - - showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> { - ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this, - DUMMY, new RuntimeException(DUMMY)); - return true; - }); - - createErrorNotificationPreference.setOnPreferenceClickListener(preference -> { - ErrorUtil.createNotification(requireContext(), - new ErrorInfo(new RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY)); - return true; - }); - } - - /** - * Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available. - * @return An {@link Optional} which is empty if the implementation class couldn't be loaded. - */ - private Optional getBVDLeakCanary() { - try { - // Try to find the implementation of the LeakCanary API - return Optional.of((DebugSettingsBVDLeakCanaryAPI) - Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS) - .getDeclaredConstructor() - .newInstance()); - } catch (final Exception e) { - return Optional.empty(); - } - } - - /** - * Build variant dependent (BVD) leak canary API for this fragment. - * Why is LeakCanary not used directly? Because it can't be assured - */ - public interface DebugSettingsBVDLeakCanaryAPI { - String IMPL_CLASS = - "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary"; - - Intent getNewLeakDisplayActivityIntent(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java deleted file mode 100644 index 356dcd9b2..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ /dev/null @@ -1,262 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.app.Activity; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.preference.Preference; -import androidx.preference.SwitchPreferenceCompat; - -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; -import org.schabi.newpipe.streams.io.StoredDirectoryHelper; -import org.schabi.newpipe.util.FilePickerActivityHelper; - -import java.io.File; -import java.io.IOException; - -public class DownloadSettingsFragment extends BasePreferenceFragment { - public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; - private String downloadPathVideoPreference; - private String downloadPathAudioPreference; - private String storageUseSafPreference; - - private Preference prefPathVideo; - private Preference prefPathAudio; - private Preference prefStorageAsk; - - private Context ctx; - private final ActivityResultLauncher requestDownloadVideoPathLauncher = - registerForActivityResult( - new StartActivityForResult(), this::requestDownloadVideoPathResult); - private final ActivityResultLauncher requestDownloadAudioPathLauncher = - registerForActivityResult( - new StartActivityForResult(), this::requestDownloadAudioPathResult); - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - downloadPathVideoPreference = getString(R.string.download_path_video_key); - downloadPathAudioPreference = getString(R.string.download_path_audio_key); - storageUseSafPreference = getString(R.string.storage_use_saf); - final String downloadStorageAsk = getString(R.string.downloads_storage_ask); - - prefPathVideo = findPreference(downloadPathVideoPreference); - prefPathAudio = findPreference(downloadPathAudioPreference); - prefStorageAsk = findPreference(downloadStorageAsk); - - final SwitchPreferenceCompat prefUseSaf = findPreference(storageUseSafPreference); - prefUseSaf.setChecked(NewPipeSettings.useStorageAccessFramework(ctx)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - prefUseSaf.setEnabled(false); - prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29); - prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice); - } - - updatePreferencesSummary(); - updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); - - if (hasInvalidPath(downloadPathVideoPreference) - || hasInvalidPath(downloadPathAudioPreference)) { - updatePreferencesSummary(); - } - - prefStorageAsk.setOnPreferenceChangeListener((preference, value) -> { - updatePathPickers(!(boolean) value); - return true; - }); - } - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - ctx = context; - } - - @Override - public void onDetach() { - super.onDetach(); - ctx = null; - prefStorageAsk.setOnPreferenceChangeListener(null); - } - - private void updatePreferencesSummary() { - showPathInSummary(downloadPathVideoPreference, R.string.download_path_summary, - prefPathVideo); - showPathInSummary(downloadPathAudioPreference, R.string.download_path_audio_summary, - prefPathAudio); - } - - private void showPathInSummary(final String prefKey, @StringRes final int defaultString, - final Preference target) { - final Uri uri = Uri.parse(defaultPreferences.getString(prefKey, "")); - if (uri.equals(Uri.EMPTY)) { - target.setSummary(getString(defaultString)); - return; - } - - final String summary = ContentResolver.SCHEME_FILE.equals(uri.getScheme()) - ? uri.getPath() : uri.toString(); - target.setSummary(summary); - } - - private boolean isFileUri(final String path) { - return path.charAt(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE); - } - - private boolean hasInvalidPath(final String prefKey) { - final String value = defaultPreferences.getString(prefKey, null); - return value == null || value.isEmpty(); - } - - private void updatePathPickers(final boolean enabled) { - prefPathVideo.setEnabled(enabled); - prefPathAudio.setEnabled(enabled); - } - - // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible - private void forgetSAFTree(final Context context, final String oldPath) { - if (IGNORE_RELEASE_ON_OLD_PATH) { - return; - } - - if (oldPath == null || oldPath.isEmpty() || isFileUri(oldPath)) { - return; - } - - try { - final Uri uri = Uri.parse(oldPath); - - context.getContentResolver() - .releasePersistableUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); - context.revokeUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); - - Log.i(TAG, "Revoke old path permissions success on " + oldPath); - } catch (final Exception err) { - Log.e(TAG, "Error revoking old path permissions on " + oldPath, err); - } - } - - private void showMessageDialog(@StringRes final int title, @StringRes final int message) { - new AlertDialog.Builder(ctx) - .setTitle(title) - .setMessage(message) - .setPositiveButton(getString(R.string.ok), null) - .show(); - } - - @Override - public boolean onPreferenceTreeClick(@NonNull final Preference preference) { - if (DEBUG) { - Log.d(TAG, "onPreferenceTreeClick() called with: " - + "preference = [" + preference + "]"); - } - - final String key = preference.getKey(); - - if (key.equals(storageUseSafPreference)) { - if (!NewPipeSettings.useStorageAccessFramework(ctx)) { - NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx); - NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx); - } else { - defaultPreferences.edit().putString(downloadPathVideoPreference, null) - .putString(downloadPathAudioPreference, null).apply(); - } - updatePreferencesSummary(); - return true; - } else if (key.equals(downloadPathVideoPreference)) { - launchDirectoryPicker(requestDownloadVideoPathLauncher); - } else if (key.equals(downloadPathAudioPreference)) { - launchDirectoryPicker(requestDownloadAudioPathLauncher); - } else { - return super.onPreferenceTreeClick(preference); - } - - return true; - } - - private void launchDirectoryPicker(final ActivityResultLauncher launcher) { - NoFileManagerSafeGuard.launchSafe( - launcher, - StoredDirectoryHelper.getPicker(ctx), - TAG, - ctx - ); - } - - private void requestDownloadVideoPathResult(final ActivityResult result) { - requestDownloadPathResult(result, downloadPathVideoPreference); - } - - private void requestDownloadAudioPathResult(final ActivityResult result) { - requestDownloadPathResult(result, downloadPathAudioPreference); - } - - private void requestDownloadPathResult(final ActivityResult result, final String key) { - if (result.getResultCode() != Activity.RESULT_OK) { - return; - } - - Uri uri = null; - if (result.getData() != null) { - uri = result.getData().getData(); - } - if (uri == null) { - showMessageDialog(R.string.general_error, R.string.invalid_directory); - return; - } - - - // revoke permissions on the old save path (required for SAF only) - final Context context = requireContext(); - - forgetSAFTree(context, defaultPreferences.getString(key, "")); - - if (!FilePickerActivityHelper.isOwnFileUri(context, uri)) { - // steps to acquire the selected path: - // 1. acquire permissions on the new save path - // 2. save the new path, if step(2) was successful - try { - context.grantUriPermission(context.getPackageName(), uri, - StoredDirectoryHelper.PERMISSION_FLAGS); - - final StoredDirectoryHelper mainStorage = - new StoredDirectoryHelper(context, uri, null); - Log.i(TAG, "Acquiring tree success from " + uri.toString()); - - if (!mainStorage.canWrite()) { - throw new IOException("No write permissions on " + uri.toString()); - } - } catch (final IOException err) { - Log.e(TAG, "Error acquiring tree from " + uri.toString(), err); - showMessageDialog(R.string.general_error, R.string.no_available_dir); - return; - } - } else { - final File target = Utils.getFileForUri(uri); - if (!target.canWrite()) { - showMessageDialog(R.string.download_to_sdcard_error_title, - R.string.download_to_sdcard_error_message); - return; - } - uri = Uri.fromFile(target); - } - - defaultPreferences.edit().putString(key, uri.toString()).apply(); - updatePreferencesSummary(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java deleted file mode 100644 index 14dd0c409..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.SharedPreferences; -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; -import androidx.preference.SwitchPreferenceCompat; - -import org.schabi.newpipe.R; - -public class ExoPlayerSettingsFragment extends BasePreferenceFragment { - - @Override - public void onCreatePreferences(@Nullable final Bundle savedInstanceState, - @Nullable final String rootKey) { - addPreferencesFromResourceRegistry(); - - final String disabledMediaTunnelingAutomaticallyKey = - getString(R.string.disabled_media_tunneling_automatically_key); - final SwitchPreferenceCompat disableMediaTunnelingPref = - (SwitchPreferenceCompat) requirePreference(R.string.disable_media_tunneling_key); - final SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - final boolean mediaTunnelingAutomaticallyDisabled = - prefs.getInt(disabledMediaTunnelingAutomaticallyKey, -1) == 1; - final String summaryText = getString(R.string.disable_media_tunneling_summary); - disableMediaTunnelingPref.setSummary(mediaTunnelingAutomaticallyDisabled - ? summaryText + " " + getString(R.string.disable_media_tunneling_automatic_info) - : summaryText); - - disableMediaTunnelingPref.setOnPreferenceChangeListener((Preference p, Object enabled) -> { - if (Boolean.FALSE.equals(enabled)) { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putInt(disabledMediaTunnelingAutomaticallyKey, 0) - .apply(); - // the info text might have been shown before - p.setSummary(R.string.disable_media_tunneling_summary); - } - return true; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java deleted file mode 100644 index 9bc9058c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Context; -import android.os.Bundle; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.preference.Preference; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.InfoCache; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class HistorySettingsFragment extends BasePreferenceFragment { - private String cacheWipeKey; - private String viewsHistoryClearKey; - private String playbackStatesClearKey; - private String searchHistoryClearKey; - private HistoryRecordManager recordManager; - private CompositeDisposable disposables; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - cacheWipeKey = getString(R.string.metadata_cache_wipe_key); - viewsHistoryClearKey = getString(R.string.clear_views_history_key); - playbackStatesClearKey = getString(R.string.clear_playback_states_key); - searchHistoryClearKey = getString(R.string.clear_search_history_key); - recordManager = new HistoryRecordManager(getActivity()); - disposables = new CompositeDisposable(); - - final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); - clearCookiePref.setOnPreferenceClickListener(preference -> { - defaultPreferences.edit() - .putString(getString(R.string.recaptcha_cookies_key), "").apply(); - DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, ""); - Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared, - Toast.LENGTH_SHORT).show(); - clearCookiePref.setEnabled(false); - return true; - }); - - if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) { - clearCookiePref.setEnabled(false); - } - } - - @Override - public boolean onPreferenceTreeClick(final Preference preference) { - if (preference.getKey().equals(cacheWipeKey)) { - InfoCache.getInstance().clearCache(); - Toast.makeText(requireContext(), - R.string.metadata_cache_wipe_complete_notice, Toast.LENGTH_SHORT).show(); - } else if (preference.getKey().equals(viewsHistoryClearKey)) { - openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables); - } else if (preference.getKey().equals(playbackStatesClearKey)) { - openDeletePlaybackStatesDialog(requireContext(), recordManager, disposables); - } else if (preference.getKey().equals(searchHistoryClearKey)) { - openDeleteSearchHistoryDialog(requireContext(), recordManager, disposables); - } else { - return super.onPreferenceTreeClick(preference); - } - return true; - } - - private static Disposable getDeletePlaybackStatesDisposable( - @NonNull final Context context, final HistoryRecordManager recordManager) { - return recordManager.deleteCompleteStreamStateHistory() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> Toast.makeText(context, - R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show(), - throwable -> ErrorUtil.openActivity(context, - new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, - "Delete playback states"))); - } - - private static Disposable getWholeStreamHistoryDisposable( - @NonNull final Context context, final HistoryRecordManager recordManager) { - return recordManager.deleteWholeStreamHistory() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> Toast.makeText(context, - R.string.watch_history_deleted, Toast.LENGTH_SHORT).show(), - throwable -> ErrorUtil.openActivity(context, - new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, - "Delete from history"))); - } - - private static Disposable getRemoveOrphanedRecordsDisposable( - @NonNull final Context context, final HistoryRecordManager recordManager) { - return recordManager.removeOrphanedRecords() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> { }, - throwable -> ErrorUtil.openActivity(context, - new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, - "Clear orphaned records"))); - } - - private static Disposable getDeleteSearchHistoryDisposable( - @NonNull final Context context, final HistoryRecordManager recordManager) { - return recordManager.deleteCompleteSearchHistory() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - howManyDeleted -> Toast.makeText(context, - R.string.search_history_deleted, Toast.LENGTH_SHORT).show(), - throwable -> ErrorUtil.openActivity(context, - new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, - "Delete search history"))); - } - - public static void openDeleteWatchHistoryDialog(@NonNull final Context context, - final HistoryRecordManager recordManager, - final CompositeDisposable disposables) { - new AlertDialog.Builder(context) - .setTitle(R.string.delete_view_history_alert) - .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) - .setPositiveButton(R.string.delete, ((dialog, which) -> { - disposables.add(getDeletePlaybackStatesDisposable(context, recordManager)); - disposables.add(getWholeStreamHistoryDisposable(context, recordManager)); - disposables.add(getRemoveOrphanedRecordsDisposable(context, recordManager)); - })) - .show(); - } - - public static void openDeletePlaybackStatesDialog(@NonNull final Context context, - final HistoryRecordManager recordManager, - final CompositeDisposable disposables) { - new AlertDialog.Builder(context) - .setTitle(R.string.delete_playback_states_alert) - .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) - .setPositiveButton(R.string.delete, ((dialog, which) -> - disposables.add(getDeletePlaybackStatesDisposable(context, recordManager)))) - .show(); - } - - public static void openDeleteSearchHistoryDialog(@NonNull final Context context, - final HistoryRecordManager recordManager, - final CompositeDisposable disposables) { - new AlertDialog.Builder(context) - .setTitle(R.string.delete_search_history_alert) - .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) - .setPositiveButton(R.string.delete, ((dialog, which) -> - disposables.add(getDeleteSearchHistoryDisposable(context, recordManager)))) - .show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java deleted file mode 100644 index cb3de39a0..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ReleaseVersionUtil; - -public class MainSettingsFragment extends BasePreferenceFragment { - public static final boolean DEBUG = MainActivity.DEBUG; - - private SettingsActivity settingsActivity; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called - - // Check if the app is updatable - if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { - getPreferenceScreen().removePreference( - requirePreference(R.string.update_pref_screen_key)); - - defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply(); - } - - // Hide debug preferences in RELEASE build variant - if (!DEBUG) { - getPreferenceScreen().removePreference( - requirePreference(R.string.debug_pref_screen_key)); - } - } - - @Override - public void onCreateOptionsMenu( - @NonNull final Menu menu, - @NonNull final MenuInflater inflater - ) { - super.onCreateOptionsMenu(menu, inflater); - - // -- Link settings activity and register menu -- - settingsActivity = (SettingsActivity) getActivity(); - - inflater.inflate(R.menu.menu_settings_main_fragment, menu); - - final MenuItem menuSearchItem = menu.getItem(0); - - settingsActivity.setMenuSearchItem(menuSearchItem); - - menuSearchItem.setOnMenuItemClickListener(ev -> { - settingsActivity.setSearchActive(true); - return true; - }); - } - - @Override - public void onDestroy() { - // Unlink activity so that we don't get memory problems - if (settingsActivity != null) { - settingsActivity.setMenuSearchItem(null); - settingsActivity = null; - } - super.onDestroy(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java deleted file mode 100644 index 3cc29afee..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ /dev/null @@ -1,188 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Build; -import android.os.Environment; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.settings.migration.MigrationManager; -import org.schabi.newpipe.util.DeviceUtils; - -import java.io.File; -import java.util.Set; - -/* - * Created by k3b on 07.01.2016. - * - * Copyright (C) Christian Schabesberger 2015 - * NewPipeSettings.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -/** - * Helper class for global settings. - */ -public final class NewPipeSettings { - private NewPipeSettings() { } - - public static void initSettings(final Context context) { - // first run migrations, then setDefaultValues, since the latter requires the correct types - MigrationManager.runMigrationsIfNeeded(context); - - // readAgain is true so that if new settings are added their default value is set - PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.video_audio_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.download_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.content_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true); - - saveDefaultVideoDownloadDirectory(context); - saveDefaultAudioDownloadDirectory(context); - - disableMediaTunnelingIfNecessary(context); - } - - static void saveDefaultVideoDownloadDirectory(final Context context) { - saveDefaultDirectory(context, R.string.download_path_video_key, - Environment.DIRECTORY_MOVIES); - } - - static void saveDefaultAudioDownloadDirectory(final Context context) { - saveDefaultDirectory(context, R.string.download_path_audio_key, - Environment.DIRECTORY_MUSIC); - } - - private static void saveDefaultDirectory(final Context context, final int keyID, - final String defaultDirectoryName) { - if (!useStorageAccessFramework(context)) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(keyID); - final String downloadPath = prefs.getString(key, null); - if (!isNullOrEmpty(downloadPath)) { - return; - } - - final SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); - spEditor.apply(); - } - } - - @NonNull - public static File getDir(final String defaultDirectoryName) { - return new File(Environment.getExternalStorageDirectory(), defaultDirectoryName); - } - - private static String getNewPipeChildFolderPathForDir(final File dir) { - return new File(dir, "NewPipe").toURI().toString(); - } - - public static boolean useStorageAccessFramework(final Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return true; - } else if (DeviceUtils.isFireTv()) { - // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with - // a remote (see #6455). - return false; - } - - final String key = context.getString(R.string.storage_use_saf); - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - return prefs.getBoolean(key, true); - } - - private static boolean showSearchSuggestions(final Context context, - final SharedPreferences sharedPreferences, - @StringRes final int key) { - final Set enabledSearchSuggestions = sharedPreferences.getStringSet( - context.getString(R.string.show_search_suggestions_key), null); - - if (enabledSearchSuggestions == null) { - return true; // defaults to true - } else { - return enabledSearchSuggestions.contains(context.getString(key)); - } - } - - public static boolean showLocalSearchSuggestions(final Context context, - final SharedPreferences sharedPreferences) { - return showSearchSuggestions(context, sharedPreferences, - R.string.show_local_search_suggestions_key); - } - - public static boolean showRemoteSearchSuggestions(final Context context, - final SharedPreferences sharedPreferences) { - return showSearchSuggestions(context, sharedPreferences, - R.string.show_remote_search_suggestions_key); - } - - private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key); - final String disabledTunnelingAutomaticallyKey = - context.getString(R.string.disabled_media_tunneling_automatically_key); - final String blacklistVersionKey = - context.getString(R.string.media_tunneling_device_blacklist_version); - - final int lastMediaTunnelingUpdate = prefs.getInt(blacklistVersionKey, 0); - final boolean wasDeviceBlacklistUpdated = - DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION != lastMediaTunnelingUpdate; - final boolean wasMediaTunnelingEnabledByUser = - prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0 - && !prefs.getBoolean(disabledTunnelingKey, false); - - if (App.getInstance().isFirstRun() - || (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) { - setMediaTunneling(context); - } - } - - /** - * Check if device does not support media tunneling - * and disable that exoplayer feature if necessary. - * @see DeviceUtils#shouldSupportMediaTunneling() - * @param context - */ - public static void setMediaTunneling(@NonNull final Context context) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - if (!DeviceUtils.shouldSupportMediaTunneling()) { - prefs.edit() - .putBoolean(context.getString(R.string.disable_media_tunneling_key), true) - .putInt(context.getString( - R.string.disabled_media_tunneling_automatically_key), 1) - .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), - DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION) - .apply(); - } else { - prefs.edit() - .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), - DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION).apply(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt deleted file mode 100644 index 11eb4fa33..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.settings - -import android.os.Bundle - -class NotificationSettingsFragment : BasePreferenceFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResourceRegistry() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt deleted file mode 100644 index d6b0a84da..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.schabi.newpipe.settings - -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import androidx.preference.Preference -import androidx.preference.SwitchPreference -import com.google.android.material.snackbar.Snackbar -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.Disposable -import org.schabi.newpipe.R -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil -import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.local.feed.notifications.NotificationHelper -import org.schabi.newpipe.local.feed.notifications.NotificationWorker -import org.schabi.newpipe.local.feed.notifications.ScheduleOptions -import org.schabi.newpipe.local.subscription.SubscriptionManager - -class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener { - - private var streamsNotificationsPreference: SwitchPreference? = null - private var notificationWarningSnackbar: Snackbar? = null - private var loader: Disposable? = null - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.notifications_settings) - streamsNotificationsPreference = requirePreference(R.string.enable_streams_notifications) - - // main check is done in onResume, but also do it here to prevent flickering - updateEnabledState(NotificationHelper.areNotificationsEnabledOnDevice(requireContext())) - } - - override fun onStart() { - super.onStart() - defaultPreferences.registerOnSharedPreferenceChangeListener(this) - } - - override fun onStop() { - defaultPreferences.unregisterOnSharedPreferenceChangeListener(this) - super.onStop() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - val context = context ?: return - if (key == getString(R.string.streams_notifications_interval_key) || - key == getString(R.string.streams_notifications_network_key) - ) { - // apply new configuration - NotificationWorker.schedule(context, ScheduleOptions.from(context), true) - } else if (key == getString(R.string.enable_streams_notifications)) { - if (NotificationHelper.areNewStreamsNotificationsEnabled(context)) { - // Start the worker, because notifications were disabled previously. - NotificationWorker.schedule(context) - } else { - // The user disabled the notifications. Cancel the worker to save energy. - // A new one will be created once the notifications are enabled again. - NotificationWorker.cancel(context) - } - } - } - - override fun onResume() { - super.onResume() - - // Check whether the notifications are disabled in the device's app settings. - // If they are disabled, show a snackbar informing the user about that - // while allowing them to open the device's app settings. - val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) - updateEnabledState(enabled) - if (!enabled) { - if (notificationWarningSnackbar == null) { - notificationWarningSnackbar = Snackbar.make( - listView, - R.string.notifications_disabled, - Snackbar.LENGTH_INDEFINITE - ).apply { - setAction(R.string.settings) { - NotificationHelper.openNewPipeSystemNotificationSettings(it.context) - } - setActionTextColor(Color.YELLOW) - addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, event: Int) { - super.onDismissed(transientBottomBar, event) - notificationWarningSnackbar = null - } - }) - show() - } - } - } - - // (Re-)Create loader - loader?.dispose() - loader = SubscriptionManager(requireContext()) - .subscriptions() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateSubscriptions, this::onError) - } - - override fun onPause() { - loader?.dispose() - loader = null - - notificationWarningSnackbar?.dismiss() - notificationWarningSnackbar = null - - super.onPause() - } - - private fun updateEnabledState(enabled: Boolean) { - // On Android 13 player notifications are exempt from notification settings - // so the preferences in app should always be available. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - streamsNotificationsPreference?.isEnabled = enabled - } else { - preferenceScreen.isEnabled = enabled - } - } - - private fun updateSubscriptions(subscriptions: List) { - val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED } - val preference = requirePreference(R.string.streams_notifications_channels_key) - preference.summary = "$notified/${subscriptions.size}" - } - - private fun onError(e: Throwable) { - ErrorUtil.showSnackbar( - this, - ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list") - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java deleted file mode 100644 index 81fddbcfb..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ /dev/null @@ -1,414 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.InputType; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.RadioButton; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListAdapter; -import androidx.recyclerview.widget.RecyclerView; - -import com.grack.nanojson.JsonStringWriter; -import com.grack.nanojson.JsonWriter; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.DialogEditTextBinding; -import org.schabi.newpipe.databinding.FragmentInstanceListBinding; -import org.schabi.newpipe.databinding.ItemInstanceBinding; -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.PeertubeHelper; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.ArrayList; -import java.util.Collections; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class PeertubeInstanceListFragment extends Fragment { - private PeertubeInstance selectedInstance; - private String savedInstanceListKey; - private InstanceListAdapter instanceListAdapter; - - private FragmentInstanceListBinding binding; - private SharedPreferences sharedPreferences; - - private CompositeDisposable disposables = new CompositeDisposable(); - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - savedInstanceListKey = getString(R.string.peertube_instance_list_key); - selectedInstance = PeertubeHelper.getCurrentInstance(); - - setHasOptionsMenu(true); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentInstanceListBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull final View rootView, - @Nullable final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - - binding.instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, - getString(R.string.peertube_instance_list_url))); - binding.addInstanceButton.setOnClickListener(v -> showAddItemDialog(requireContext())); - binding.instances.setLayoutManager(new LinearLayoutManager(requireContext())); - - final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(binding.instances); - - instanceListAdapter = new InstanceListAdapter(requireContext(), itemTouchHelper); - binding.instances.setAdapter(instanceListAdapter); - instanceListAdapter.submitList(PeertubeHelper.getInstanceList(requireContext())); - } - - @Override - public void onResume() { - super.onResume(); - ThemeHelper.setTitleToAppCompatActivity(getActivity(), - getString(R.string.peertube_instance_url_title)); - } - - @Override - public void onPause() { - super.onPause(); - saveChanges(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (disposables != null) { - disposables.clear(); - } - disposables = null; - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_chooser_fragment, menu); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == R.id.menu_item_restore_default) { - restoreDefaults(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void selectInstance(final PeertubeInstance instance) { - selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()); - sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, true).apply(); - } - - private void saveChanges() { - final JsonStringWriter jsonWriter = JsonWriter.string().object().array("instances"); - for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { - jsonWriter.object(); - jsonWriter.value("name", instance.getName()); - jsonWriter.value("url", instance.getUrl()); - jsonWriter.end(); - } - final String jsonToSave = jsonWriter.end().end().done(); - sharedPreferences.edit().putString(savedInstanceListKey, jsonToSave).apply(); - } - - private void restoreDefaults() { - final Context context = requireContext(); - new AlertDialog.Builder(context) - .setTitle(R.string.restore_defaults) - .setMessage(R.string.restore_defaults_confirmation) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> { - sharedPreferences.edit().remove(savedInstanceListKey).apply(); - selectInstance(PeertubeInstance.DEFAULT_INSTANCE); - instanceListAdapter.submitList(PeertubeHelper.getInstanceList(context)); - }) - .show(); - } - - private void showAddItemDialog(final Context c) { - final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); - dialogBinding.dialogEditText.setInputType( - InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); - dialogBinding.dialogEditText.setHint(R.string.peertube_instance_add_help); - - new AlertDialog.Builder(c) - .setTitle(R.string.peertube_instance_add_title) - .setIcon(R.drawable.ic_placeholder_peertube) - .setView(dialogBinding.getRoot()) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog1, which) -> { - final String url = dialogBinding.dialogEditText.getText().toString(); - addInstance(url); - }) - .show(); - } - - private void addInstance(final String url) { - final String cleanUrl = cleanUrl(url); - if (cleanUrl == null) { - return; - } - binding.loadingProgressBar.setVisibility(View.VISIBLE); - final Disposable disposable = Single.fromCallable(() -> { - final PeertubeInstance instance = new PeertubeInstance(cleanUrl); - instance.fetchInstanceMetaData(); - return instance; - }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) - .subscribe((instance) -> { - binding.loadingProgressBar.setVisibility(View.GONE); - add(instance); - }, e -> { - binding.loadingProgressBar.setVisibility(View.GONE); - Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, - Toast.LENGTH_SHORT).show(); - }); - disposables.add(disposable); - } - - @Nullable - private String cleanUrl(final String url) { - String cleanUrl = url.trim(); - // if protocol not present, add https - if (!cleanUrl.startsWith("http")) { - cleanUrl = "https://" + cleanUrl; - } - // remove trailing slash - cleanUrl = cleanUrl.replaceAll("/$", ""); - // only allow https - if (!cleanUrl.startsWith("https://")) { - Toast.makeText(getActivity(), R.string.peertube_instance_add_https_only, - Toast.LENGTH_SHORT).show(); - return null; - } - // only allow if not already exists - for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { - if (instance.getUrl().equals(cleanUrl)) { - Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, - Toast.LENGTH_SHORT).show(); - return null; - } - } - return cleanUrl; - } - - private void add(final PeertubeInstance instance) { - final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); - list.add(instance); - instanceListAdapter.submitList(list); - } - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, - ItemTouchHelper.START | ItemTouchHelper.END) { - @Override - public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, - final int viewSize, - final int viewSizeOutOfBounds, - final int totalSize, - final long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, - viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int minimumAbsVelocity = Math.max(12, Math.abs(standardSpeed)); - return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder source, - @NonNull final RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType() - || instanceListAdapter == null) { - return false; - } - - final int sourceIndex = source.getBindingAdapterPosition(); - final int targetIndex = target.getBindingAdapterPosition(); - instanceListAdapter.swapItems(sourceIndex, targetIndex); - return true; - } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return true; - } - - @Override - public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, - final int swipeDir) { - final int position = viewHolder.getBindingAdapterPosition(); - // do not allow swiping the selected instance - if (instanceListAdapter.getCurrentList().get(position).getUrl() - .equals(selectedInstance.getUrl())) { - instanceListAdapter.notifyItemChanged(position); - return; - } - final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); - list.remove(position); - - if (list.isEmpty()) { - list.add(selectedInstance); - } - - instanceListAdapter.submitList(list); - } - }; - } - - /*////////////////////////////////////////////////////////////////////////// - // List Handling - //////////////////////////////////////////////////////////////////////////*/ - - private class InstanceListAdapter - extends ListAdapter { - private final LayoutInflater inflater; - private final ItemTouchHelper itemTouchHelper; - private RadioButton lastChecked; - - InstanceListAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { - super(new PeertubeInstanceCallback()); - this.itemTouchHelper = itemTouchHelper; - this.inflater = LayoutInflater.from(context); - } - - public void swapItems(final int fromPosition, final int toPosition) { - final var list = new ArrayList<>(getCurrentList()); - Collections.swap(list, fromPosition, toPosition); - submitList(list); - } - - @NonNull - @Override - public InstanceListAdapter.TabViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - return new InstanceListAdapter.TabViewHolder(ItemInstanceBinding.inflate(inflater, - parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull final InstanceListAdapter.TabViewHolder holder, - final int position) { - holder.bind(position); - } - - class TabViewHolder extends RecyclerView.ViewHolder { - private final ItemInstanceBinding itemBinding; - - TabViewHolder(final ItemInstanceBinding binding) { - super(binding.getRoot()); - this.itemBinding = binding; - } - - @SuppressLint("ClickableViewAccessibility") - void bind(final int position) { - itemBinding.handle.setOnTouchListener((view, motionEvent) -> { - if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - if (itemTouchHelper != null && getItemCount() > 1) { - itemTouchHelper.startDrag(this); - return true; - } - } - return false; - }); - - final PeertubeInstance instance = getItem(position); - itemBinding.instanceName.setText(instance.getName()); - itemBinding.instanceUrl.setText(instance.getUrl()); - itemBinding.selectInstanceRB.setOnCheckedChangeListener(null); - if (selectedInstance.getUrl().equals(instance.getUrl())) { - if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { - lastChecked.setChecked(false); - } - itemBinding.selectInstanceRB.setChecked(true); - lastChecked = itemBinding.selectInstanceRB; - } - itemBinding.selectInstanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - selectInstance(instance); - if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { - lastChecked.setChecked(false); - } - lastChecked = itemBinding.selectInstanceRB; - } - }); - itemBinding.instanceIcon.setImageResource(R.drawable.ic_placeholder_peertube); - } - } - } - - private static final class PeertubeInstanceCallback - extends DiffUtil.ItemCallback { - @Override - public boolean areItemsTheSame(@NonNull final PeertubeInstance oldItem, - @NonNull final PeertubeInstance newItem) { - return oldItem.getUrl().equals(newItem.getUrl()); - } - - @Override - public boolean areContentsTheSame(@NonNull final PeertubeInstance oldItem, - @NonNull final PeertubeInstance newItem) { - return oldItem.getName().equals(newItem.getName()) - && oldItem.getUrl().equals(newItem.getUrl()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt deleted file mode 100644 index 7d95433a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.settings - -import android.os.Bundle - -class PlayerNotificationSettingsFragment : BasePreferenceFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResourceRegistry() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java deleted file mode 100644 index f1af8c66d..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings; - -import android.content.DialogInterface; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.local.subscription.SubscriptionManager; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.List; -import java.util.Vector; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observer; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SelectChannelFragment extends DialogFragment { - - private OnSelectedListener onSelectedListener = null; - private OnCancelListener onCancelListener = null; - - private ProgressBar progressBar; - private TextView emptyView; - private RecyclerView recyclerView; - - private List subscriptions = new Vector<>(); - - public void setOnSelectedListener(final OnSelectedListener listener) { - onSelectedListener = listener; - } - - public void setOnCancelListener(final OnCancelListener listener) { - onCancelListener = listener; - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View v = inflater.inflate(R.layout.select_channel_fragment, container, false); - recyclerView = v.findViewById(R.id.items_list); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - final SelectChannelAdapter channelAdapter = new SelectChannelAdapter(); - recyclerView.setAdapter(channelAdapter); - - progressBar = v.findViewById(R.id.progressBar); - emptyView = v.findViewById(R.id.empty_state_view); - progressBar.setVisibility(View.VISIBLE); - recyclerView.setVisibility(View.GONE); - emptyView.setVisibility(View.GONE); - - - final SubscriptionManager subscriptionManager = new SubscriptionManager(requireContext()); - subscriptionManager.subscriptions().toObservable() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriptionObserver()); - - return v; - } - - /*////////////////////////////////////////////////////////////////////////// - // Handle actions - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCancel(@NonNull final DialogInterface dialogInterface) { - super.onCancel(dialogInterface); - if (onCancelListener != null) { - onCancelListener.onCancel(); - } - } - - private void clickedItem(final int position) { - if (onSelectedListener != null) { - final SubscriptionEntity entry = subscriptions.get(position); - onSelectedListener - .onChannelSelected(entry.getServiceId(), entry.getUrl(), entry.getName()); - } - dismiss(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Item handling - //////////////////////////////////////////////////////////////////////////*/ - - private void displayChannels(final List newSubscriptions) { - this.subscriptions = newSubscriptions; - progressBar.setVisibility(View.GONE); - if (newSubscriptions.isEmpty()) { - emptyView.setVisibility(View.VISIBLE); - return; - } - recyclerView.setVisibility(View.VISIBLE); - - } - - private Observer> getSubscriptionObserver() { - return new Observer>() { - @Override - public void onSubscribe(@NonNull final Disposable disposable) { } - - @Override - public void onNext(@NonNull final List newSubscriptions) { - displayChannels(newSubscriptions); - } - - @Override - public void onError(@NonNull final Throwable exception) { - ErrorUtil.showUiErrorSnackbar(SelectChannelFragment.this, - "Loading subscription", exception); - } - - @Override - public void onComplete() { } - }; - } - - /*////////////////////////////////////////////////////////////////////////// - // Interfaces - //////////////////////////////////////////////////////////////////////////*/ - - public interface OnSelectedListener { - void onChannelSelected(int serviceId, String url, String name); - } - - public interface OnCancelListener { - void onCancel(); - } - - private final class SelectChannelAdapter - extends RecyclerView.Adapter { - @NonNull - @Override - public SelectChannelItemHolder onCreateViewHolder(final ViewGroup parent, - final int viewType) { - final View item = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.select_channel_item, parent, false); - return new SelectChannelItemHolder(item); - } - - @Override - public void onBindViewHolder(final SelectChannelItemHolder holder, final int position) { - final SubscriptionEntity entry = subscriptions.get(position); - holder.titleView.setText(entry.getName()); - holder.view.setOnClickListener(view -> clickedItem(position)); - CoilHelper.INSTANCE.loadAvatar(holder.thumbnailView, entry.getAvatarUrl()); - } - - @Override - public int getItemCount() { - return subscriptions.size(); - } - - public class SelectChannelItemHolder extends RecyclerView.ViewHolder { - public final View view; - final ImageView thumbnailView; - final TextView titleView; - SelectChannelItemHolder(final View v) { - super(v); - this.view = v; - thumbnailView = v.findViewById(R.id.itemThumbnailView); - titleView = v.findViewById(R.id.itemTitleView); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java deleted file mode 100644 index 79838bb3c..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java +++ /dev/null @@ -1,198 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings; - -import android.content.DialogInterface; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.feed.model.FeedGroupEntity; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.List; -import java.util.Vector; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observer; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SelectFeedGroupFragment extends DialogFragment { - - private OnSelectedListener onSelectedListener = null; - private OnCancelListener onCancelListener = null; - - private ProgressBar progressBar; - private TextView emptyView; - private RecyclerView recyclerView; - - private List feedGroups = new Vector<>(); - - public void setOnSelectedListener(final OnSelectedListener listener) { - onSelectedListener = listener; - } - - public void setOnCancelListener(final OnCancelListener listener) { - onCancelListener = listener; - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View v = inflater.inflate(R.layout.select_feed_group_fragment, container, false); - recyclerView = v.findViewById(R.id.items_list); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - final SelectFeedGroupAdapter feedGroupAdapter = new SelectFeedGroupAdapter(); - recyclerView.setAdapter(feedGroupAdapter); - - progressBar = v.findViewById(R.id.progressBar); - emptyView = v.findViewById(R.id.empty_state_view); - progressBar.setVisibility(View.VISIBLE); - recyclerView.setVisibility(View.GONE); - emptyView.setVisibility(View.GONE); - - - final AppDatabase database = NewPipeDatabase.getInstance(requireContext()); - database.feedGroupDAO().getAll().toObservable() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getFeedGroupObserver()); - - return v; - } - - /*////////////////////////////////////////////////////////////////////////// - // Handle actions - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCancel(@NonNull final DialogInterface dialogInterface) { - super.onCancel(dialogInterface); - if (onCancelListener != null) { - onCancelListener.onCancel(); - } - } - - private void clickedItem(final int position) { - if (onSelectedListener != null) { - final FeedGroupEntity entry = feedGroups.get(position); - onSelectedListener - .onFeedGroupSelected(entry.getUid(), entry.getName(), - entry.getIcon().getDrawableResource()); - } - dismiss(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Item handling - //////////////////////////////////////////////////////////////////////////*/ - - private void displayFeedGroups(final List newFeedGroups) { - this.feedGroups = newFeedGroups; - progressBar.setVisibility(View.GONE); - if (newFeedGroups.isEmpty()) { - emptyView.setVisibility(View.VISIBLE); - return; - } - recyclerView.setVisibility(View.VISIBLE); - - } - - private Observer> getFeedGroupObserver() { - return new Observer>() { - @Override - public void onSubscribe(@NonNull final Disposable disposable) { } - - @Override - public void onNext(@NonNull final List newGroups) { - displayFeedGroups(newGroups); - } - - @Override - public void onError(@NonNull final Throwable exception) { - ErrorUtil.showUiErrorSnackbar(SelectFeedGroupFragment.this, - "Loading Feed Groups", exception); - } - - @Override - public void onComplete() { } - }; - } - - /*////////////////////////////////////////////////////////////////////////// - // Interfaces - //////////////////////////////////////////////////////////////////////////*/ - - public interface OnSelectedListener { - void onFeedGroupSelected(Long groupId, String name, int icon); - } - - public interface OnCancelListener { - void onCancel(); - } - - private final class SelectFeedGroupAdapter - extends RecyclerView.Adapter { - @NonNull - @Override - public SelectFeedGroupItemHolder onCreateViewHolder(final ViewGroup parent, - final int viewType) { - final View item = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.select_feed_group_item, parent, false); - return new SelectFeedGroupItemHolder(item); - } - - @Override - public void onBindViewHolder(final SelectFeedGroupItemHolder holder, final int position) { - final FeedGroupEntity entry = feedGroups.get(position); - holder.titleView.setText(entry.getName()); - holder.view.setOnClickListener(view -> clickedItem(position)); - holder.thumbnailView.setImageResource(entry.getIcon().getDrawableResource()); - } - - @Override - public int getItemCount() { - return feedGroups.size(); - } - - public class SelectFeedGroupItemHolder extends RecyclerView.ViewHolder { - public final View view; - final ImageView thumbnailView; - final TextView titleView; - SelectFeedGroupItemHolder(final View v) { - super(v); - this.view = v; - thumbnailView = v.findViewById(R.id.itemThumbnailView); - titleView = v.findViewById(R.id.itemTitleView); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java deleted file mode 100644 index d7e72821f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.fragment.app.DialogFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.util.KioskTranslator; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.List; -import java.util.Vector; - -public class SelectKioskFragment extends DialogFragment { - private SelectKioskAdapter selectKioskAdapter = null; - - private OnSelectedListener onSelectedListener = null; - - public void setOnSelectedListener(final OnSelectedListener listener) { - onSelectedListener = listener; - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); - } - - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View v = inflater.inflate(R.layout.select_kiosk_fragment, container, false); - final RecyclerView recyclerView = v.findViewById(R.id.items_list); - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - try { - selectKioskAdapter = new SelectKioskAdapter(); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Selecting kiosk", e); - } - recyclerView.setAdapter(selectKioskAdapter); - - return v; - } - - /*////////////////////////////////////////////////////////////////////////// - // Handle actions - //////////////////////////////////////////////////////////////////////////*/ - - private void clickedItem(final SelectKioskAdapter.Entry entry) { - if (onSelectedListener != null) { - onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName); - } - dismiss(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Interfaces - //////////////////////////////////////////////////////////////////////////*/ - - public interface OnSelectedListener { - void onKioskSelected(int serviceId, String kioskId, String kioskName); - } - - private class SelectKioskAdapter - extends RecyclerView.Adapter { - private final List kioskList = new Vector<>(); - - SelectKioskAdapter() throws Exception { - for (final StreamingService service : NewPipe.getServices()) { - for (final String kioskId : service.getKioskList().getAvailableKiosks()) { - final String name = String.format(getString(R.string.service_kiosk_string), - service.getServiceInfo().getName(), - KioskTranslator.getTranslatedKioskName(kioskId, getContext())); - kioskList.add(new Entry(ServiceHelper.getIcon(service.getServiceId()), - service.getServiceId(), kioskId, name)); - } - } - } - - public int getItemCount() { - return kioskList.size(); - } - - @NonNull - public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { - final View item = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.select_kiosk_item, parent, false); - return new SelectKioskItemHolder(item); - } - - public void onBindViewHolder(final SelectKioskItemHolder holder, final int position) { - final Entry entry = kioskList.get(position); - holder.titleView.setText(entry.kioskName); - holder.thumbnailView - .setImageDrawable(AppCompatResources.getDrawable(requireContext(), entry.icon)); - holder.view.setOnClickListener(view -> clickedItem(entry)); - } - - class Entry { - final int icon; - final int serviceId; - final String kioskId; - final String kioskName; - - Entry(final int i, final int si, final String ki, final String kn) { - icon = i; - serviceId = si; - kioskId = ki; - kioskName = kn; - } - } - - public class SelectKioskItemHolder extends RecyclerView.ViewHolder { - public final View view; - final ImageView thumbnailView; - final TextView titleView; - - SelectKioskItemHolder(final View v) { - super(v); - this.view = v; - thumbnailView = v.findViewById(R.id.itemThumbnailView); - titleView = v.findViewById(R.id.itemTitleView); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java deleted file mode 100644 index ea475cb4f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.fragment.app.DialogFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.List; -import java.util.Vector; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.Disposable; - -public class SelectPlaylistFragment extends DialogFragment { - - private OnSelectedListener onSelectedListener = null; - - private ProgressBar progressBar; - private TextView emptyView; - private RecyclerView recyclerView; - private Disposable disposable = null; - - private List playlists = new Vector<>(); - - public void setOnSelectedListener(final OnSelectedListener listener) { - onSelectedListener = listener; - } - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View v = inflater.inflate(R.layout.select_playlist_fragment, container, false); - progressBar = v.findViewById(R.id.progressBar); - recyclerView = v.findViewById(R.id.items_list); - emptyView = v.findViewById(R.id.empty_state_view); - - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter(); - recyclerView.setAdapter(playlistAdapter); - - loadPlaylists(); - return v; - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (disposable != null) { - disposable.dispose(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and display playlists - //////////////////////////////////////////////////////////////////////////*/ - - private void loadPlaylists() { - progressBar.setVisibility(View.VISIBLE); - recyclerView.setVisibility(View.GONE); - emptyView.setVisibility(View.GONE); - - final AppDatabase database = NewPipeDatabase.getInstance(requireContext()); - final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database); - final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database); - - disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::displayPlaylists, this::onError); - } - - private void displayPlaylists(final List newPlaylists) { - playlists = newPlaylists; - progressBar.setVisibility(View.GONE); - emptyView.setVisibility(newPlaylists.isEmpty() ? View.VISIBLE : View.GONE); - recyclerView.setVisibility(newPlaylists.isEmpty() ? View.GONE : View.VISIBLE); - } - - protected void onError(final Throwable e) { - ErrorUtil.showSnackbar(requireActivity(), new ErrorInfo(e, - UserAction.UI_ERROR, "Loading playlists")); - } - - /*////////////////////////////////////////////////////////////////////////// - // Handle actions - //////////////////////////////////////////////////////////////////////////*/ - - private void clickedItem(final int position) { - if (onSelectedListener != null) { - final LocalItem selectedItem = playlists.get(position); - - if (selectedItem instanceof PlaylistMetadataEntry) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.getOrderingName()); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - onSelectedListener.onRemotePlaylistSelected( - entry.getServiceId(), entry.getUrl(), entry.getOrderingName()); - } - } - dismiss(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Interfaces - //////////////////////////////////////////////////////////////////////////*/ - - public interface OnSelectedListener { - void onLocalPlaylistSelected(long id, String name); - void onRemotePlaylistSelected(int serviceId, String url, String name); - } - - private final class SelectPlaylistAdapter - extends RecyclerView.Adapter { - @NonNull - @Override - public SelectPlaylistItemHolder onCreateViewHolder(final ViewGroup parent, - final int viewType) { - final View item = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.list_playlist_mini_item, parent, false); - return new SelectPlaylistItemHolder(item); - } - - @Override - public void onBindViewHolder(@NonNull final SelectPlaylistItemHolder holder, - final int position) { - final PlaylistLocalItem selectedItem = playlists.get(position); - - if (selectedItem instanceof PlaylistMetadataEntry entry) { - holder.titleView.setText(entry.getOrderingName()); - holder.view.setOnClickListener(view -> clickedItem(position)); - CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, - entry.getThumbnailUrl()); - - } else if (selectedItem instanceof PlaylistRemoteEntity entry) { - holder.titleView.setText(entry.getOrderingName()); - holder.view.setOnClickListener(view -> clickedItem(position)); - CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, - entry.getThumbnailUrl()); - } - } - - @Override - public int getItemCount() { - return playlists.size(); - } - - public class SelectPlaylistItemHolder extends RecyclerView.ViewHolder { - public final View view; - final ImageView thumbnailView; - final TextView titleView; - - SelectPlaylistItemHolder(final View v) { - super(v); - this.view = v; - thumbnailView = v.findViewById(R.id.itemThumbnailView); - titleView = v.findViewById(R.id.itemTitleView); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java deleted file mode 100644 index d5089cb7d..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ /dev/null @@ -1,388 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Context; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.EditText; - -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; - -import com.evernote.android.state.State; -import com.jakewharton.rxbinding4.widget.RxTextView; -import com.livefront.bridge.Bridge; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.SettingsLayoutBinding; -import org.schabi.newpipe.settings.preferencesearch.PreferenceParser; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener; -import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.KeyboardUtil; -import org.schabi.newpipe.util.ReleaseVersionUtil; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.FocusOverlayView; - -import java.util.concurrent.TimeUnit; - -/* - * Created by Christian Schabesberger on 31.08.15. - * - * Copyright (C) Christian Schabesberger 2015 - * SettingsActivity.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class SettingsActivity extends AppCompatActivity implements - PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, - PreferenceSearchResultListener { - private static final String TAG = "SettingsActivity"; - private static final boolean DEBUG = MainActivity.DEBUG; - - @IdRes - private static final int FRAGMENT_HOLDER_ID = R.id.settings_fragment_holder; - - private PreferenceSearchFragment searchFragment; - - @Nullable - private MenuItem menuSearchItem; - - private View searchContainer; - private EditText searchEditText; - - // State - @State - String searchText; - @State - boolean wasSearchActive; - - @Override - protected void onCreate(final Bundle savedInstanceBundle) { - setTheme(ThemeHelper.getSettingsThemeStyle(this)); - - super.onCreate(savedInstanceBundle); - Bridge.restoreInstanceState(this, savedInstanceBundle); - final boolean restored = savedInstanceBundle != null; - - final SettingsLayoutBinding settingsLayoutBinding = - SettingsLayoutBinding.inflate(getLayoutInflater()); - setContentView(settingsLayoutBinding.getRoot()); - initSearch(settingsLayoutBinding, restored); - - setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar); - - if (restored) { - // Restore state - if (this.wasSearchActive) { - setSearchActive(true); - if (!TextUtils.isEmpty(this.searchText)) { - this.searchEditText.setText(this.searchText); - } - } - } else { - getSupportFragmentManager().beginTransaction() - .replace(R.id.settings_fragment_holder, new MainSettingsFragment()) - .commit(); - } - - if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(this); - } - } - - @Override - protected void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Bridge.saveInstanceState(this, outState); - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowTitleEnabled(true); - } - - return super.onCreateOptionsMenu(menu); - } - - @Override - public void onBackPressed() { - if (isSearchActive()) { - setSearchActive(false); - return; - } - super.onBackPressed(); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final int id = item.getItemId(); - if (id == android.R.id.home) { - // Check if the search is active and if so: Close it - if (isSearchActive()) { - setSearchActive(false); - return true; - } - - if (getSupportFragmentManager().getBackStackEntryCount() == 0) { - finish(); - } else { - getSupportFragmentManager().popBackStack(); - } - } - - return super.onOptionsItemSelected(item); - } - - @Override - public boolean onPreferenceStartFragment(@NonNull final PreferenceFragmentCompat caller, - final Preference preference) { - showSettingsFragment(instantiateFragment(preference.getFragment())); - return true; - } - - private Fragment instantiateFragment(@NonNull final String className) { - return getSupportFragmentManager() - .getFragmentFactory() - .instantiate(this.getClassLoader(), className); - } - - private void showSettingsFragment(final Fragment fragment) { - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, - R.animator.custom_fade_in, R.animator.custom_fade_out) - .replace(FRAGMENT_HOLDER_ID, fragment) - .addToBackStack(null) - .commit(); - } - - @Override - protected void onDestroy() { - setMenuSearchItem(null); - searchFragment = null; - super.onDestroy(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Search - //////////////////////////////////////////////////////////////////////////*/ - //region Search - - private void initSearch( - final SettingsLayoutBinding settingsLayoutBinding, - final boolean restored - ) { - searchContainer = - settingsLayoutBinding.settingsToolbarLayout.toolbar - .findViewById(R.id.toolbar_search_container); - - // Configure input field for search - searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text); - RxTextView.textChanges(searchEditText) - // Wait some time after the last input before actually searching - .debounce(200, TimeUnit.MILLISECONDS) - .subscribe(v -> runOnUiThread(this::onSearchChanged)); - - // Configure clear button - searchContainer.findViewById(R.id.toolbar_search_clear) - .setOnClickListener(ev -> resetSearchText()); - - ensureSearchRepresentsApplicationState(); - - // Build search configuration using SettingsResourceRegistry - final PreferenceSearchConfiguration config = new PreferenceSearchConfiguration(); - - - // Build search items - final Context searchContext = getApplicationContext(); - final PreferenceParser parser = new PreferenceParser(searchContext, config); - final PreferenceSearcher searcher = new PreferenceSearcher(config); - - // Find all searchable SettingsResourceRegistry fragments - SettingsResourceRegistry.getInstance().getAllEntries().stream() - .filter(SettingsResourceRegistry.SettingRegistryEntry::isSearchable) - // Get the resId - .map(SettingsResourceRegistry.SettingRegistryEntry::getPreferencesResId) - // Parse - .map(parser::parse) - // Add it to the searcher - .forEach(searcher::add); - - if (restored) { - searchFragment = (PreferenceSearchFragment) getSupportFragmentManager() - .findFragmentByTag(PreferenceSearchFragment.NAME); - if (searchFragment != null) { - // Hide/Remove the search fragment otherwise we get an exception - // when adding it (because it's already present) - hideSearchFragment(); - } - } - if (searchFragment == null) { - searchFragment = new PreferenceSearchFragment(); - } - searchFragment.setSearcher(searcher); - } - - /** - * Ensures that the search shows the correct/available search results. - *
- * Some features are e.g. only available for debug builds, these should not - * be found when searching inside a release. - */ - private void ensureSearchRepresentsApplicationState() { - // Check if the update settings are available - if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { - SettingsResourceRegistry.getInstance() - .getEntryByPreferencesResId(R.xml.update_settings) - .setSearchable(false); - } - - // Hide debug preferences in RELEASE build variant - if (DEBUG) { - SettingsResourceRegistry.getInstance() - .getEntryByPreferencesResId(R.xml.debug_settings) - .setSearchable(true); - } - } - - public void setMenuSearchItem(final MenuItem menuSearchItem) { - this.menuSearchItem = menuSearchItem; - - // Ensure that the item is in the correct state when adding it. This is due to - // Android's lifecycle (the Activity is recreated before the Fragment that registers this) - if (menuSearchItem != null) { - menuSearchItem.setVisible(!isSearchActive()); - } - } - - public void setSearchActive(final boolean active) { - if (DEBUG) { - Log.d(TAG, "setSearchActive called active=" + active); - } - - // Ignore if search is already in correct state - if (isSearchActive() == active) { - return; - } - - wasSearchActive = active; - - searchContainer.setVisibility(active ? View.VISIBLE : View.GONE); - if (menuSearchItem != null) { - menuSearchItem.setVisible(!active); - } - - if (active) { - getSupportFragmentManager() - .beginTransaction() - .add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME) - .addToBackStack(PreferenceSearchFragment.NAME) - .commit(); - - KeyboardUtil.showKeyboard(this, searchEditText); - } else if (searchFragment != null) { - hideSearchFragment(); - getSupportFragmentManager() - .popBackStack( - PreferenceSearchFragment.NAME, - FragmentManager.POP_BACK_STACK_INCLUSIVE); - - KeyboardUtil.hideKeyboard(this, searchEditText); - } - - resetSearchText(); - } - - private void hideSearchFragment() { - getSupportFragmentManager().beginTransaction().remove(searchFragment).commit(); - } - - private void resetSearchText() { - searchEditText.setText(""); - } - - private boolean isSearchActive() { - return searchContainer.getVisibility() == View.VISIBLE; - } - - private void onSearchChanged() { - if (!isSearchActive()) { - return; - } - - if (searchFragment != null) { - searchText = this.searchEditText.getText().toString(); - searchFragment.updateSearchResults(searchText); - } - } - - @Override - public void onSearchResultClicked(@NonNull final PreferenceSearchItem result) { - if (DEBUG) { - Log.d(TAG, "onSearchResultClicked called result=" + result); - } - - // Hide the search - setSearchActive(false); - - // -- Highlight the result -- - // Find out which fragment class we need - final Class targetedFragmentClass = - SettingsResourceRegistry.getInstance() - .getFragmentClass(result.getSearchIndexItemResId()); - - if (targetedFragmentClass == null) { - // This should never happen - Log.w(TAG, "Unable to locate fragment class for resId=" - + result.getSearchIndexItemResId()); - return; - } - - // Check if the currentFragment is the one which contains the result - Fragment currentFragment = - getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID); - if (!targetedFragmentClass.equals(currentFragment.getClass())) { - // If it's not the correct one display the correct one - currentFragment = instantiateFragment(targetedFragmentClass.getName()); - showSettingsFragment(currentFragment); - } - - // Run the highlighting - if (currentFragment instanceof PreferenceFragmentCompat) { - PreferenceSearchResultHighlighter - .highlight(result, (PreferenceFragmentCompat) currentFragment); - } - } - - //endregion -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java deleted file mode 100644 index 06e0a7c1e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ /dev/null @@ -1,152 +0,0 @@ -package org.schabi.newpipe.settings; - -import androidx.annotation.NonNull; -import androidx.annotation.XmlRes; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.R; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; - -/** - * A registry that contains information about SettingsFragments. - *
- * includes: - *
    - *
  • Class of the SettingsFragment
  • - *
  • XML-Resource
  • - *
  • ...
  • - *
- * - * E.g. used by the preference search. - */ -public final class SettingsResourceRegistry { - - private static final SettingsResourceRegistry INSTANCE = new SettingsResourceRegistry(); - - private final Set registeredEntries = new HashSet<>(); - - private SettingsResourceRegistry() { - add(MainSettingsFragment.class, R.xml.main_settings).setSearchable(false); - - add(AppearanceSettingsFragment.class, R.xml.appearance_settings); - add(ContentSettingsFragment.class, R.xml.content_settings); - add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false); - add(DownloadSettingsFragment.class, R.xml.download_settings); - add(HistorySettingsFragment.class, R.xml.history_settings); - add(NotificationSettingsFragment.class, R.xml.notifications_settings); - add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings); - add(UpdateSettingsFragment.class, R.xml.update_settings); - add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); - add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); - add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); - } - - private SettingRegistryEntry add( - @NonNull final Class fragmentClass, - @XmlRes final int preferencesResId - ) { - final SettingRegistryEntry entry = - new SettingRegistryEntry(fragmentClass, preferencesResId); - this.registeredEntries.add(entry); - return entry; - } - - public SettingRegistryEntry getEntryByFragmentClass( - final Class fragmentClass - ) { - Objects.requireNonNull(fragmentClass); - return registeredEntries.stream() - .filter(e -> Objects.equals(e.getFragmentClass(), fragmentClass)) - .findFirst() - .orElse(null); - } - - public SettingRegistryEntry getEntryByPreferencesResId(@XmlRes final int preferencesResId) { - return registeredEntries.stream() - .filter(e -> Objects.equals(e.getPreferencesResId(), preferencesResId)) - .findFirst() - .orElse(null); - } - - public int getPreferencesResId(@NonNull final Class fragmentClass) { - final SettingRegistryEntry entry = getEntryByFragmentClass(fragmentClass); - if (entry == null) { - return -1; - } - return entry.getPreferencesResId(); - } - - public Class getFragmentClass(@XmlRes final int preferencesResId) { - final SettingRegistryEntry entry = getEntryByPreferencesResId(preferencesResId); - if (entry == null) { - return null; - } - return entry.getFragmentClass(); - } - - public Set getAllEntries() { - return new HashSet<>(registeredEntries); - } - - public static SettingsResourceRegistry getInstance() { - return INSTANCE; - } - - - public static class SettingRegistryEntry { - @NonNull - private final Class fragmentClass; - @XmlRes - private final int preferencesResId; - - private boolean searchable = true; - - public SettingRegistryEntry( - @NonNull final Class fragmentClass, - @XmlRes final int preferencesResId - ) { - this.fragmentClass = Objects.requireNonNull(fragmentClass); - this.preferencesResId = preferencesResId; - } - - @SuppressWarnings("HiddenField") - public SettingRegistryEntry setSearchable(final boolean searchable) { - this.searchable = searchable; - return this; - } - - @NonNull - public Class getFragmentClass() { - return fragmentClass; - } - - public int getPreferencesResId() { - return preferencesResId; - } - - public boolean isSearchable() { - return searchable; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - final SettingRegistryEntry that = (SettingRegistryEntry) o; - return getPreferencesResId() == that.getPreferencesResId() - && getFragmentClass().equals(that.getFragmentClass()); - } - - @Override - public int hashCode() { - return Objects.hash(getFragmentClass(), getPreferencesResId()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java deleted file mode 100644 index 8923972b0..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.app.AlertDialog; -import android.content.Context; -import android.os.Bundle; -import android.widget.Toast; - -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.NewVersionWorker; -import org.schabi.newpipe.R; - -public class UpdateSettingsFragment extends BasePreferenceFragment { - private final Preference.OnPreferenceChangeListener updatePreferenceChange = (p, nVal) -> { - final boolean checkForUpdates = (boolean) nVal; - defaultPreferences.edit() - .putBoolean(getString(R.string.update_app_key), checkForUpdates) - .apply(); - - if (checkForUpdates) { - NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); - } - return true; - }; - - private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> { - Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show(); - NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); - return true; - }; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - requirePreference(R.string.update_app_key) - .setOnPreferenceChangeListener(updatePreferenceChange); - requirePreference(R.string.manual_update_key) - .setOnPreferenceClickListener(manualUpdateClick); - } - - public static void askForConsentToUpdateChecks(final Context context) { - new AlertDialog.Builder(context) - .setTitle(context.getString(R.string.check_for_updates)) - .setMessage(context.getString(R.string.auto_update_check_description)) - .setPositiveButton(context.getString(R.string.yes), (d, w) -> { - d.dismiss(); - setAutoUpdateCheckEnabled(context, true); - }) - .setNegativeButton(R.string.no, (d, w) -> { - d.dismiss(); - // set explicitly to false, since the default is true on previous versions - setAutoUpdateCheckEnabled(context, false); - }) - .show(); - } - - private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) { - PreferenceManager.getDefaultSharedPreferences(context) - .edit() - .putBoolean(context.getString(R.string.update_app_key), enabled) - .putBoolean(context.getString(R.string.update_check_consent_key), true) - .apply(); - } - - /** - * Whether the user was asked for consent to automatically check for app updates. - * @param context - * @return true if the user was asked for consent, false otherwise - */ - public static boolean wasUserAskedForConsent(final Context context) { - return PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.update_check_consent_key), false); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java deleted file mode 100644 index a4d52592f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ /dev/null @@ -1,192 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.os.Bundle; -import android.provider.Settings; -import android.text.format.DateUtils; -import android.widget.Toast; - -import androidx.preference.ListPreference; - -import com.google.android.material.snackbar.Snackbar; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.PermissionHelper; - -import java.util.LinkedList; -import java.util.List; - -public class VideoAudioSettingsFragment extends BasePreferenceFragment { - private SharedPreferences.OnSharedPreferenceChangeListener listener; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - updateSeekOptions(); - updateResolutionOptions(); - listener = (sharedPreferences, key) -> { - - // on M and above, if user chooses to minimise to popup player on exit - // and the app doesn't have display over other apps permission, - // show a snackbar to let the user give permission - if (getString(R.string.minimize_on_exit_key).equals(key)) { - final String newSetting = sharedPreferences.getString(key, null); - if (newSetting != null - && newSetting.equals(getString(R.string.minimize_on_exit_popup_key)) - && !Settings.canDrawOverlays(getContext())) { - - Snackbar.make(getListView(), R.string.permission_display_over_apps, - Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.settings, view -> - PermissionHelper.checkSystemAlertWindowPermission(getContext())) - .show(); - - } - } else if (getString(R.string.use_inexact_seek_key).equals(key)) { - updateSeekOptions(); - } else if (getString(R.string.show_higher_resolutions_key).equals(key)) { - updateResolutionOptions(); - } - }; - } - - /** - * Update default resolution, default popup resolution & mobile data resolution options. - *
- * Show high resolutions when "Show higher resolution" option is enabled. - * Set default resolution to "best resolution" when "Show higher resolution" option - * is disabled. - */ - private void updateResolutionOptions() { - final Resources resources = getResources(); - final boolean showHigherResolutions = getPreferenceManager().getSharedPreferences() - .getBoolean(resources.getString(R.string.show_higher_resolutions_key), false); - - // get sorted resolution lists - final List resolutionListDescriptions = ListHelper.getSortedResolutionList( - resources, - R.array.resolution_list_description, - R.array.high_resolution_list_descriptions, - showHigherResolutions); - final List resolutionListValues = ListHelper.getSortedResolutionList( - resources, - R.array.resolution_list_values, - R.array.high_resolution_list_values, - showHigherResolutions); - final List limitDataUsageResolutionValues = ListHelper.getSortedResolutionList( - resources, - R.array.limit_data_usage_values_list, - R.array.high_resolution_limit_data_usage_values_list, - showHigherResolutions); - final List limitDataUsageResolutionDescriptions = ListHelper - .getSortedResolutionList(resources, - R.array.limit_data_usage_description_list, - R.array.high_resolution_list_descriptions, - showHigherResolutions); - - // get resolution preferences - final ListPreference defaultResolution = requirePreference( - R.string.default_resolution_key); - final ListPreference defaultPopupResolution = requirePreference( - R.string.default_popup_resolution_key); - final ListPreference mobileDataResolution = requirePreference( - R.string.limit_mobile_data_usage_key); - - // update resolution preferences with new resolutions, entries & values for each - defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); - defaultResolution.setEntryValues(resolutionListValues.toArray(new String[0])); - defaultPopupResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); - defaultPopupResolution.setEntryValues(resolutionListValues.toArray(new String[0])); - mobileDataResolution.setEntries( - limitDataUsageResolutionDescriptions.toArray(new String[0])); - mobileDataResolution.setEntryValues(limitDataUsageResolutionValues.toArray(new String[0])); - - // if "Show higher resolution" option is disabled, - // set default resolution to "best resolution" - if (!showHigherResolutions) { - if (ListHelper.isHighResolutionSelected(defaultResolution.getValue(), - R.array.high_resolution_list_values, - resources)) { - defaultResolution.setValueIndex(0); - } - if (ListHelper.isHighResolutionSelected(defaultPopupResolution.getValue(), - R.array.high_resolution_list_values, - resources)) { - defaultPopupResolution.setValueIndex(0); - } - if (ListHelper.isHighResolutionSelected(mobileDataResolution.getValue(), - R.array.high_resolution_limit_data_usage_values_list, - resources)) { - mobileDataResolution.setValueIndex(0); - } - } - } - - /** - * Update fast-forward/-rewind seek duration options - * according to language and inexact seek setting. - * Exoplayer can't seek 5 seconds in audio when using inexact seek. - */ - private void updateSeekOptions() { - // initializing R.array.seek_duration_description to display the translation of seconds - final Resources res = getResources(); - final String[] durationsValues = res.getStringArray(R.array.seek_duration_value); - final List displayedDurationValues = new LinkedList<>(); - final List displayedDescriptionValues = new LinkedList<>(); - int currentDurationValue; - final boolean inexactSeek = getPreferenceManager().getSharedPreferences() - .getBoolean(res.getString(R.string.use_inexact_seek_key), false); - - for (final String durationsValue : durationsValues) { - currentDurationValue = - Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS; - if (inexactSeek && currentDurationValue % 10 == 5) { - continue; - } - - displayedDurationValues.add(durationsValue); - try { - displayedDescriptionValues.add(String.format( - res.getQuantityString(R.plurals.seconds, - currentDurationValue), - currentDurationValue)); - } catch (final Resources.NotFoundException ignored) { - // if this happens, the translation is missing, - // and the english string will be displayed instead - } - } - - final ListPreference durations = requirePreference(R.string.seek_duration_key); - durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0])); - durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0])); - final int selectedDuration = Integer.parseInt(durations.getValue()); - if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) { - final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5; - durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS)); - - final Toast toast = Toast - .makeText(getContext(), - getString(R.string.new_seek_duration_toast, newDuration), - Toast.LENGTH_LONG); - toast.show(); - } - } - - @Override - public void onResume() { - super.onResume(); - getPreferenceManager().getSharedPreferences() - .registerOnSharedPreferenceChangeListener(listener); - - } - - @Override - public void onPause() { - super.onPause(); - getPreferenceManager().getSharedPreferences() - .unregisterOnSharedPreferenceChangeListener(listener); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt deleted file mode 100644 index f0b89c677..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.settings.custom - -import android.content.Context -import android.util.AttributeSet -import androidx.preference.ListPreference -import org.schabi.newpipe.util.Localization - -/** - * An extension of a common ListPreference where it sets the duration values to human readable strings. - * - * The values in the entry values array will be interpreted as seconds. If the value of a specific position - * is less than or equals to zero, its original entry title will be used. - * - * If the entry values array have anything other than numbers in it, an exception will be raised. - */ -class DurationListPreference : ListPreference { - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context) : super(context) - - override fun onAttached() { - super.onAttached() - - val originalEntryTitles = entries - val originalEntryValues = entryValues - val newEntryTitles = arrayOfNulls(originalEntryValues.size) - - for (i in originalEntryValues.indices) { - val currentDurationValue: Int - try { - currentDurationValue = (originalEntryValues[i] as String).toInt() - } catch (e: NumberFormatException) { - throw RuntimeException("Invalid number was set in the preference entry values array", e) - } - - if (currentDurationValue <= 0) { - newEntryTitles[i] = originalEntryTitles[i] - } else { - newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue) - } - } - - entries = newEntryTitles - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java deleted file mode 100644 index 7dfddef20..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.schabi.newpipe.settings.custom; - -import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Build; -import android.util.AttributeSet; -import android.view.View; -import android.widget.CheckBox; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.preference.Preference; -import androidx.preference.PreferenceViewHolder; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.player.notification.NotificationConstants; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.IntStream; - -public class NotificationActionsPreference extends Preference { - - public NotificationActionsPreference(final Context context, final AttributeSet attrs) { - super(context, attrs); - setLayoutResource(R.layout.settings_notification); - } - - - private NotificationSlot[] notificationSlots; - private List compactSlots; - - - //////////////////////////////////////////////////////////////////////////// - // Lifecycle - //////////////////////////////////////////////////////////////////////////// - - @Override - public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ((TextView) holder.itemView.findViewById(R.id.summary)) - .setText(R.string.notification_actions_summary_android13); - } - - holder.itemView.setClickable(false); - setupActions(holder.itemView); - } - - @Override - public void onDetached() { - super.onDetached(); - saveChanges(); - // set package to this app's package to prevent the intent from being seen outside - getContext().sendBroadcast(new Intent(ACTION_RECREATE_NOTIFICATION) - .setPackage(App.PACKAGE_NAME)); - } - - - //////////////////////////////////////////////////////////////////////////// - // Setup - //////////////////////////////////////////////////////////////////////////// - - private void setupActions(@NonNull final View view) { - compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences( - getContext(), getSharedPreferences())); - notificationSlots = IntStream.range(0, 5) - .mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view, - compactSlots.contains(i), this::onToggleCompactSlot)) - .toArray(NotificationSlot[]::new); - } - - private void onToggleCompactSlot(final int i, final CheckBox checkBox) { - if (checkBox.isChecked()) { - compactSlots.remove((Integer) i); - } else if (compactSlots.size() < 3) { - compactSlots.add(i); - } else { - Toast.makeText(getContext(), - R.string.notification_actions_at_most_three, - Toast.LENGTH_SHORT).show(); - return; - } - - checkBox.toggle(); - } - - - //////////////////////////////////////////////////////////////////////////// - // Saving - //////////////////////////////////////////////////////////////////////////// - - private void saveChanges() { - if (compactSlots != null && notificationSlots != null) { - final SharedPreferences.Editor editor = getSharedPreferences().edit(); - - for (int i = 0; i < 3; i++) { - editor.putInt(getContext().getString( - NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), - (i < compactSlots.size() ? compactSlots.get(i) : -1)); - } - - for (int i = 0; i < 5; i++) { - editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - notificationSlots[i].getSelectedAction()); - } - - editor.apply(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java deleted file mode 100644 index 981ba3e75..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.schabi.newpipe.settings.custom; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.ColorStateList; -import android.os.Build; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.ImageView; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.TextView; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.widget.TextViewCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ListRadioIconItemBinding; -import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; -import org.schabi.newpipe.player.notification.NotificationConstants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.FocusOverlayView; - -import java.util.Objects; -import java.util.function.BiConsumer; - -class NotificationSlot { - - private static final int[] SLOT_ITEMS = { - R.id.notificationAction0, - R.id.notificationAction1, - R.id.notificationAction2, - R.id.notificationAction3, - R.id.notificationAction4, - }; - - private static final int[] SLOT_TITLES = { - R.string.notification_action_0_title, - R.string.notification_action_1_title, - R.string.notification_action_2_title, - R.string.notification_action_3_title, - R.string.notification_action_4_title, - }; - - private final int i; - private @NotificationConstants.Action int selectedAction; - private final Context context; - private final BiConsumer onToggleCompactSlot; - - private ImageView icon; - private TextView summary; - - NotificationSlot(final Context context, - final SharedPreferences prefs, - final int actionIndex, - final View parentView, - final boolean isCompactSlotChecked, - final BiConsumer onToggleCompactSlot) { - this.context = context; - this.i = actionIndex; - this.onToggleCompactSlot = onToggleCompactSlot; - - selectedAction = Objects.requireNonNull(prefs).getInt( - context.getString(NotificationConstants.SLOT_PREF_KEYS[i]), - NotificationConstants.SLOT_DEFAULTS[i]); - final View view = parentView.findViewById(SLOT_ITEMS[i]); - - // only show the last two notification slots on Android 13+ - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) { - setupSelectedAction(view); - setupTitle(view); - setupCheckbox(view, isCompactSlotChecked); - } else { - view.setVisibility(View.GONE); - } - } - - void setupTitle(final View view) { - ((TextView) view.findViewById(R.id.notificationActionTitle)) - .setText(SLOT_TITLES[i]); - view.findViewById(R.id.notificationActionClickableArea).setOnClickListener( - v -> openActionChooserDialog()); - } - - void setupCheckbox(final View view, final boolean isCompactSlotChecked) { - final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // there are no compact slots to customize on Android 13+ - compactSlotCheckBox.setVisibility(View.GONE); - view.findViewById(R.id.notificationActionCheckBoxClickableArea) - .setVisibility(View.GONE); - return; - } - - compactSlotCheckBox.setChecked(isCompactSlotChecked); - view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener( - v -> onToggleCompactSlot.accept(i, compactSlotCheckBox)); - } - - void setupSelectedAction(final View view) { - icon = view.findViewById(R.id.notificationActionIcon); - summary = view.findViewById(R.id.notificationActionSummary); - updateInfo(); - } - - void updateInfo() { - if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) { - icon.setImageDrawable(null); - } else { - icon.setImageDrawable(AppCompatResources.getDrawable(context, - NotificationConstants.ACTION_ICONS[selectedAction])); - } - - summary.setText(NotificationConstants.getActionName(context, selectedAction)); - } - - void openActionChooserDialog() { - final LayoutInflater inflater = LayoutInflater.from(context); - final SingleChoiceDialogViewBinding binding = - SingleChoiceDialogViewBinding.inflate(inflater); - - final AlertDialog alertDialog = new AlertDialog.Builder(context) - .setTitle(SLOT_TITLES[i]) - .setView(binding.getRoot()) - .setCancelable(true) - .create(); - - final View.OnClickListener radioButtonsClickListener = v -> { - selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()]; - updateInfo(); - alertDialog.dismiss(); - }; - - for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) { - final int action = NotificationConstants.ALL_ACTIONS[id]; - final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater) - .getRoot(); - - // if present set action icon with correct color - final int iconId = NotificationConstants.ACTION_ICONS[action]; - if (iconId != 0) { - radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0); - - final var color = ColorStateList.valueOf(ThemeHelper - .resolveColorFromAttr(context, android.R.attr.textColorPrimary)); - TextViewCompat.setCompoundDrawableTintList(radioButton, color); - } - - radioButton.setText(NotificationConstants.getActionName(context, action)); - radioButton.setChecked(action == selectedAction); - radioButton.setId(id); - radioButton.setLayoutParams(new RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - radioButton.setOnClickListener(radioButtonsClickListener); - binding.list.addView(radioButton); - } - alertDialog.show(); - - if (DeviceUtils.isTv(context)) { - FocusOverlayView.setupFocusObserver(alertDialog); - } - } - - @NotificationConstants.Action - public int getSelectedAction() { - return selectedAction; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt deleted file mode 100644 index 97a7e642f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.schabi.newpipe.settings.export - -import android.content.Context -import java.nio.file.Path -import kotlin.io.path.div - -/** - * Locates specific files of NewPipe based on the home directory of the app. - */ -class BackupFileLocator(context: Context) { - companion object { - const val FILE_NAME_DB = "newpipe.db" - - @Deprecated( - "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", - replaceWith = ReplaceWith("FILE_NAME_JSON_PREFS") - ) - const val FILE_NAME_SERIALIZED_PREFS = "newpipe.settings" - const val FILE_NAME_JSON_PREFS = "preferences.json" - } - - val db: Path = context.getDatabasePath(FILE_NAME_DB).toPath() - val dbJournal: Path = db.resolveSibling("$FILE_NAME_DB-journal") - val dbShm: Path = db.resolveSibling("$FILE_NAME_DB-shm") - val dbWal: Path = db.resolveSibling("$FILE_NAME_DB-wal") -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt deleted file mode 100644 index b5ab72f51..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt +++ /dev/null @@ -1,186 +0,0 @@ -package org.schabi.newpipe.settings.export - -import android.content.SharedPreferences -import com.grack.nanojson.JsonArray -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonParserException -import com.grack.nanojson.JsonWriter -import java.io.FileNotFoundException -import java.io.IOException -import java.io.ObjectOutputStream -import java.util.zip.ZipOutputStream -import kotlin.io.path.createParentDirectories -import kotlin.io.path.deleteIfExists -import org.schabi.newpipe.streams.io.SharpOutputStream -import org.schabi.newpipe.streams.io.StoredFileHelper -import org.schabi.newpipe.util.ZipHelper - -class ImportExportManager(private val fileLocator: BackupFileLocator) { - companion object { - const val TAG = "ImportExportManager" - } - - /** - * Exports given [SharedPreferences] to the file in given outputPath. - * It also creates the file. - */ - @Throws(Exception::class) - fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) { - // truncate the file before writing to it, otherwise if the new content is smaller than the - // previous file size, the file will retain part of the previous content and be corrupted - ZipOutputStream(SharpOutputStream(file.openAndTruncateStream()).buffered()).use { outZip -> - // add the database - val name = BackupFileLocator.FILE_NAME_DB - ZipHelper.addFileToZip(outZip, name, fileLocator.db) - - // add the legacy vulnerable serialized preferences (will be removed in the future) - ZipHelper.addFileToZip( - outZip, - BackupFileLocator.FILE_NAME_SERIALIZED_PREFS - ) { byteOutput -> - ObjectOutputStream(byteOutput).use { output -> - output.writeObject(preferences.all) - output.flush() - } - } - - // add the JSON preferences - ZipHelper.addFileToZip( - outZip, - BackupFileLocator.FILE_NAME_JSON_PREFS - ) { byteOutput -> - JsonWriter - .indent("") - .on(byteOutput) - .`object`(preferences.all) - .done() - } - } - } - - /** - * Tries to create database directory if it does not exist. - */ - @Throws(IOException::class) - fun ensureDbDirectoryExists() { - fileLocator.db.createParentDirectories() - } - - /** - * Extracts the database from the given file to the app's database directory. - * The current app's database will be overwritten. - * @param file the .zip file to extract the database from - * @return true if the database was successfully extracted, false otherwise - */ - fun extractDb(file: StoredFileHelper): Boolean { - val name = BackupFileLocator.FILE_NAME_DB - val success = ZipHelper.extractFileFromZip(file, name, fileLocator.db) - - if (success) { - fileLocator.dbJournal.deleteIfExists() - fileLocator.dbWal.deleteIfExists() - fileLocator.dbShm.deleteIfExists() - } - - return success - } - - @Deprecated( - "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", - replaceWith = ReplaceWith("exportHasJsonPrefs") - ) - fun exportHasSerializedPrefs(zipFile: StoredFileHelper): Boolean { - return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) - } - - fun exportHasJsonPrefs(zipFile: StoredFileHelper): Boolean { - return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) - } - - /** - * Remove all shared preferences from the app and load the preferences supplied to the manager. - */ - @Deprecated( - "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", - replaceWith = ReplaceWith("loadJsonPrefs") - ) - @Throws(IOException::class, ClassNotFoundException::class) - fun loadSerializedPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) { - ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) { - PreferencesObjectInputStream(it).use { input -> - @Suppress("UNCHECKED_CAST") - val entries = input.readObject() as Map - - val editor = preferences.edit() - editor.clear() - - for ((key, value) in entries) { - when (value) { - is Boolean -> editor.putBoolean(key, value) - - is Float -> editor.putFloat(key, value) - - is Int -> editor.putInt(key, value) - - is Long -> editor.putLong(key, value) - - is String -> editor.putString(key, value) - - is Set<*> -> { - // There are currently only Sets with type String possible - @Suppress("UNCHECKED_CAST") - editor.putStringSet(key, value as Set?) - } - } - } - - if (!editor.commit()) { - throw IOException("Unable to commit loadSerializedPrefs") - } - } - }.let { fileExists -> - if (!fileExists) { - throw FileNotFoundException(BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) - } - } - } - - /** - * Remove all shared preferences from the app and load the preferences supplied to the manager. - */ - @Throws(IOException::class, JsonParserException::class) - fun loadJsonPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) { - ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) { - val jsonObject = JsonParser.`object`().from(it) - - val editor = preferences.edit() - editor.clear() - - for ((key, value) in jsonObject) { - when (value) { - is Boolean -> editor.putBoolean(key, value) - - is Float -> editor.putFloat(key, value) - - is Int -> editor.putInt(key, value) - - is Long -> editor.putLong(key, value) - - is String -> editor.putString(key, value) - - is JsonArray -> { - editor.putStringSet(key, value.mapNotNull { e -> e as? String }.toSet()) - } - } - } - - if (!editor.commit()) { - throw IOException("Unable to commit loadJsonPrefs") - } - }.let { fileExists -> - if (!fileExists) { - throw FileNotFoundException(BackupFileLocator.FILE_NAME_JSON_PREFS) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.kt b/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.kt deleted file mode 100644 index 5f564a7a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings.export - -import java.io.IOException -import java.io.InputStream -import java.io.ObjectInputStream -import java.io.ObjectStreamClass - -/** - * An [ObjectInputStream] that only allows preferences-related types to be deserialized, to - * prevent injections. The only allowed types are: all primitive types, all boxed primitive types, - * null, strings. HashMap, HashSet and arrays of previously defined types are also allowed. Sources: - * [cmu.edu](https://wiki.sei.cmu.edu/confluence/display/java/SER00-J.+Enable+serialization+compatibility+during+class+evolution) * , - * [OWASP cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html#harden-your-own-javaioobjectinputstream) * , - * [Apache's `ValidatingObjectInputStream`](https://commons.apache.org/proper/commons-io/apidocs/src-html/org/apache/commons/io/serialization/ValidatingObjectInputStream.html#line-118) * - */ -class PreferencesObjectInputStream(stream: InputStream) : ObjectInputStream(stream) { - @Throws(ClassNotFoundException::class, IOException::class) - override fun resolveClass(desc: ObjectStreamClass): Class<*> { - if (desc.name in CLASS_WHITELIST) { - return super.resolveClass(desc) - } else { - throw ClassNotFoundException("Class not allowed: $desc.name") - } - } - - companion object { - /** - * Primitive types, strings and other built-in types do not pass through resolveClass() but - * instead have a custom encoding; see - * [ - * official docs](https://docs.oracle.com/javase/6/docs/platform/serialization/spec/protocol.html#10152). - */ - private val CLASS_WHITELIST = setOf( - "java.lang.Boolean", - "java.lang.Byte", - "java.lang.Character", - "java.lang.Short", - "java.lang.Integer", - "java.lang.Long", - "java.lang.Float", - "java.lang.Double", - "java.lang.Void", - "java.util.HashMap", - "java.util.HashSet" - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java b/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java deleted file mode 100644 index d5b0e783d..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.schabi.newpipe.settings.migration; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.core.util.Consumer; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; - -import java.util.ArrayList; -import java.util.List; - -/** - * MigrationManager is responsible for running migrations and showing the user information about - * the migrations that were applied. - */ -public final class MigrationManager { - - private static final String TAG = MigrationManager.class.getSimpleName(); - /** - * List of UI actions that are performed after the UI is initialized (e.g. showing alert - * dialogs) to inform the user about changes that were applied by migrations. - */ - private static final List> MIGRATION_INFO = new ArrayList<>(); - - private MigrationManager() { - // MigrationManager is a utility class that is completely static - } - - /** - * Run all migrations that are needed for the current version of NewPipe. - * This method should be called at the start of the application, before any other operations - * that depend on the settings. - * - * @param context Context that can be used to run migrations - */ - public static void runMigrationsIfNeeded(@NonNull final Context context) { - SettingMigrations.runMigrationsIfNeeded(context); - } - - /** - * Perform UI actions informing about migrations that took place if they are present. - * @param context Context that can be used to show dialogs/snackbars/toasts - */ - public static void showUserInfoIfPresent(@NonNull final Context context) { - if (MIGRATION_INFO.isEmpty()) { - return; - } - - try { - MIGRATION_INFO.get(0).accept(context); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e); - // Remove the migration that caused the error and continue with the next one - MIGRATION_INFO.remove(0); - showUserInfoIfPresent(context); - } - } - - /** - * Add a migration info action that will be executed after the UI is initialized. - * This can be used to show dialogs/snackbars/toasts to inform the user about changes that - * were applied by migrations. - * - * @param info the action to be executed - */ - public static void addMigrationInfo(final Consumer info) { - MIGRATION_INFO.add(info); - } - - /** - * This method should be called when the user dismisses the migration info - * to check if there are any more migration info actions to be shown. - * @param context Context that can be used to show dialogs/snackbars/toasts - */ - public static void onMigrationInfoDismissed(@NonNull final Context context) { - MIGRATION_INFO.remove(0); - showUserInfoIfPresent(context); - } - - /** - * Creates a dialog to inform the user about the migration. - * @param uiContext Context that can be used to show dialogs/snackbars/toasts - * @param title the title of the dialog - * @param message the message of the dialog - * @return the dialog that can be shown to the user with a custom dismiss listener - */ - static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext, - @NonNull final String title, - @NonNull final String message) { - return new AlertDialog.Builder(uiContext) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .setOnDismissListener(dialog -> - MigrationManager.onMigrationInfoDismissed(uiContext)) - .setCancelable(false) // prevents the dialog from being dismissed accidentally - .create(); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java deleted file mode 100644 index 92520ec7e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java +++ /dev/null @@ -1,316 +0,0 @@ -package org.schabi.newpipe.settings.migration; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; -import static org.schabi.newpipe.extractor.ServiceList.YouTube; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.util.Consumer; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.settings.tabs.Tab; -import org.schabi.newpipe.settings.tabs.TabsManager; -import org.schabi.newpipe.util.DeviceUtils; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * This class contains the code to migrate the settings from one version to another. - * Migrations are run automatically when the app is started and the settings version changed. - *
- * In order to add a migration, follow these steps, given {@code P} is the previous version: - *
    - *
  • in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put - * in the {@code migrate()} method the code that need to be run - * when migrating from {@code P} to {@code P+1}
  • - *
  • add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
  • - *
  • increment {@link SettingMigrations#VERSION}'s value by 1 - * (so it becomes {@code P+1})
  • - *
- * Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)} - * that will be performed after the UI is initialized to inform the user about changes - * that were applied by migrations. - */ -public final class SettingMigrations { - - private static final String TAG = SettingMigrations.class.toString(); - private static SharedPreferences sp; - - private static final Migration MIGRATION_0_1 = new Migration(0, 1) { - @Override - public void migrate(@NonNull final Context context) { - // We changed the content of the dialog which opens when sharing a link to NewPipe - // by removing the "open detail page" option. - // Therefore, show the dialog once again to ensure users need to choose again and are - // aware of the changed dialog. - final SharedPreferences.Editor editor = sp.edit(); - editor.putString(context.getString(R.string.preferred_open_action_key), - context.getString(R.string.always_ask_open_action_key)); - editor.apply(); - } - }; - - private static final Migration MIGRATION_1_2 = new Migration(1, 2) { - @Override - protected void migrate(@NonNull final Context context) { - // The new application workflow introduced in #2907 allows minimizing videos - // while playing to do other stuff within the app. - // For an even better workflow, we minimize a stream when switching the app to play in - // background. - // Therefore, set default value to background, if it has not been changed yet. - final String minimizeOnExitKey = context.getString(R.string.minimize_on_exit_key); - if (sp.getString(minimizeOnExitKey, "") - .equals(context.getString(R.string.minimize_on_exit_none_key))) { - final SharedPreferences.Editor editor = sp.edit(); - editor.putString(minimizeOnExitKey, - context.getString(R.string.minimize_on_exit_background_key)); - editor.apply(); - } - } - }; - - private static final Migration MIGRATION_2_3 = new Migration(2, 3) { - @Override - protected void migrate(@NonNull final Context context) { - // Storage Access Framework implementation was improved in #5415, allowing the modern - // and standard way to access folders and files to be used consistently everywhere. - // We reset the setting to its default value, i.e. "use SAF", since now there are no - // more issues with SAF and users should use that one instead of the old - // NoNonsenseFilePicker. Also, there's a bug on FireOS in which SAF open/close - // dialogs cannot be confirmed with a remote (see #6455). - sp.edit().putBoolean( - context.getString(R.string.storage_use_saf), - !DeviceUtils.isFireTv() - ).apply(); - } - }; - - private static final Migration MIGRATION_3_4 = new Migration(3, 4) { - @Override - protected void migrate(@NonNull final Context context) { - // Pull request #3546 added support for choosing the type of search suggestions to - // show, replacing the on-off switch used before, so migrate the previous user choice - - final String showSearchSuggestionsKey = - context.getString(R.string.show_search_suggestions_key); - - boolean addAllSearchSuggestionTypes; - try { - addAllSearchSuggestionTypes = sp.getBoolean(showSearchSuggestionsKey, true); - } catch (final ClassCastException e) { - // just in case it was not a boolean for some reason, let's consider it a "true" - addAllSearchSuggestionTypes = true; - } - - final Set showSearchSuggestionsValueList = new HashSet<>(); - if (addAllSearchSuggestionTypes) { - // if the preference was true, all suggestions will be shown, otherwise none - Collections.addAll(showSearchSuggestionsValueList, context.getResources() - .getStringArray(R.array.show_search_suggestions_value_list)); - } - - sp.edit().putStringSet( - showSearchSuggestionsKey, showSearchSuggestionsValueList).apply(); - } - }; - - private static final Migration MIGRATION_4_5 = new Migration(4, 5) { - @Override - protected void migrate(@NonNull final Context context) { - final boolean brightness = sp.getBoolean("brightness_gesture_control", true); - final boolean volume = sp.getBoolean("volume_gesture_control", true); - - final SharedPreferences.Editor editor = sp.edit(); - - editor.putString(context.getString(R.string.right_gesture_control_key), - context.getString(volume - ? R.string.volume_control_key : R.string.none_control_key)); - editor.putString(context.getString(R.string.left_gesture_control_key), - context.getString(brightness - ? R.string.brightness_control_key : R.string.none_control_key)); - - editor.apply(); - } - }; - - private static final Migration MIGRATION_5_6 = new Migration(5, 6) { - @Override - protected void migrate(@NonNull final Context context) { - final boolean loadImages = sp.getBoolean("download_thumbnail_key", true); - - sp.edit() - .putString(context.getString(R.string.image_quality_key), - context.getString(loadImages - ? R.string.image_quality_default - : R.string.image_quality_none_key)) - .apply(); - } - }; - - private static final Migration MIGRATION_6_7 = new Migration(6, 7) { - @Override - protected void migrate(@NonNull final Context context) { - // The SoundCloud Top 50 Kiosk was removed in the extractor, - // so we remove the corresponding tab if it exists. - final TabsManager tabsManager = TabsManager.getManager(context); - final List tabs = tabsManager.getTabs(); - final List cleanedTabs = tabs.stream() - .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab - && kioskTab.getKioskServiceId() == SoundCloud.getServiceId() - && kioskTab.getKioskId().equals("Top 50"))) - .collect(Collectors.toUnmodifiableList()); - if (tabs.size() != cleanedTabs.size()) { - tabsManager.saveTabs(cleanedTabs); - // create an AlertDialog to inform the user about the change - MigrationManager.addMigrationInfo(uiContext -> - MigrationManager.createMigrationInfoDialog( - uiContext, - uiContext.getString(R.string.migration_info_6_7_title), - uiContext.getString(R.string.migration_info_6_7_message)) - .show()); - } - } - }; - - private static final Migration MIGRATION_7_8 = new Migration(7, 8) { - @Override - protected void migrate(@NonNull final Context context) { - // YouTube remove the combined Trending kiosk, see - // https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information. - // If the user has a dedicated YouTube/Trending kiosk tab, - // it is removed and replaced with the new live kiosk tab. - // The default trending kiosk tab is not touched - // because it uses the default kiosk provided by the extractor - // and is thus updated automatically. - final TabsManager tabsManager = TabsManager.getManager(context); - final List tabs = tabsManager.getTabs(); - final List cleanedTabs = tabs.stream() - .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab - && kioskTab.getKioskServiceId() == YouTube.getServiceId() - && kioskTab.getKioskId().equals("Trending"))) - .collect(Collectors.toUnmodifiableList()); - if (tabs.size() != cleanedTabs.size()) { - tabsManager.saveTabs(cleanedTabs); - } - - final boolean hasDefaultTrendingTab = tabs.stream() - .anyMatch(tab -> tab instanceof Tab.DefaultKioskTab); - - if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) { - // User is informed about the change - MigrationManager.addMigrationInfo(uiContext -> - MigrationManager.createMigrationInfoDialog( - uiContext, - uiContext.getString(R.string.migration_info_7_8_title), - uiContext.getString(R.string.migration_info_7_8_message)) - .show()); - } - } - }; - - /** - * List of all implemented migrations. - *

- * Append new migrations to the end of the list to keep it sorted ascending. - * If not sorted correctly, migrations which depend on each other, may fail. - */ - private static final Migration[] SETTING_MIGRATIONS = { - MIGRATION_0_1, - MIGRATION_1_2, - MIGRATION_2_3, - MIGRATION_3_4, - MIGRATION_4_5, - MIGRATION_5_6, - MIGRATION_6_7, - MIGRATION_7_8, - }; - - /** - * Version number for preferences. Must be incremented every time a migration is necessary. - */ - private static final int VERSION = 8; - - - static void runMigrationsIfNeeded(@NonNull final Context context) { - // setup migrations and check if there is something to do - sp = PreferenceManager.getDefaultSharedPreferences(context); - final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version); - final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0); - - // no migration to run, already up to date - if (App.getInstance().isFirstRun()) { - sp.edit().putInt(lastPrefVersionKey, VERSION).apply(); - return; - } else if (lastPrefVersion == VERSION) { - return; - } - - // run migrations - int currentVersion = lastPrefVersion; - for (final Migration currentMigration : SETTING_MIGRATIONS) { - try { - if (currentMigration.shouldMigrate(currentVersion)) { - if (DEBUG) { - Log.d(TAG, "Migrating preferences from version " - + currentVersion + " to " + currentMigration.newVersion); - } - currentMigration.migrate(context); - currentVersion = currentMigration.newVersion; - } - } catch (final Exception e) { - // save the version with the last successful migration and report the error - sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); - ErrorUtil.openActivity(context, new ErrorInfo( - e, - UserAction.PREFERENCES_MIGRATION, - "Migrating preferences from version " + lastPrefVersion + " to " - + VERSION + ". " - + "Error at " + currentVersion + " => " + ++currentVersion - )); - return; - } - } - - // store the current preferences version - sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); - } - - private SettingMigrations() { } - - abstract static class Migration { - public final int oldVersion; - public final int newVersion; - - protected Migration(final int oldVersion, final int newVersion) { - this.oldVersion = oldVersion; - this.newVersion = newVersion; - } - - /** - * @param currentVersion current settings version - * @return Returns whether this migration should be run. - * A migration is necessary if the old version of this migration is lower than or equal to - * the current settings version. - */ - private boolean shouldMigrate(final int currentVersion) { - return oldVersion >= currentVersion; - } - - protected abstract void migrate(@NonNull Context context); - - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt deleted file mode 100644 index fd8abfa16..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.schabi.newpipe.settings.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.databinding.ItemNotificationConfigBinding -import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder - -/** - * This [RecyclerView.Adapter] is used in the [NotificationModeConfigFragment]. - * The adapter holds all subscribed channels and their [NotificationMode]s - * and provides the needed data structures and methods for this task. - */ -class NotificationModeConfigAdapter( - private val listener: ModeToggleListener -) : ListAdapter(DiffCallback) { - override fun onCreateViewHolder(parent: ViewGroup, i: Int): SubscriptionHolder { - return SubscriptionHolder( - ItemNotificationConfigBinding - .inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindViewHolder(holder: SubscriptionHolder, position: Int) { - holder.bind(currentList[position]) - } - - fun update(newData: List) { - val items = newData.map { - SubscriptionItem(it.uid, it.name!!, it.notificationMode, it.serviceId, it.url!!) - } - submitList(items) - } - - inner class SubscriptionHolder( - private val itemBinding: ItemNotificationConfigBinding - ) : RecyclerView.ViewHolder(itemBinding.root) { - init { - itemView.setOnClickListener { - val mode = if (itemBinding.root.isChecked) { - NotificationMode.DISABLED - } else { - NotificationMode.ENABLED - } - listener.onModeChange(bindingAdapterPosition, mode) - } - } - - fun bind(data: SubscriptionItem) { - itemBinding.root.text = data.title - itemBinding.root.isChecked = data.notificationMode != NotificationMode.DISABLED - } - } - - private object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { - return oldItem == newItem - } - - override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? { - return if (oldItem.notificationMode != newItem.notificationMode) { - newItem.notificationMode - } else { - super.getChangePayload(oldItem, newItem) - } - } - } - - fun interface ModeToggleListener { - /** - * Triggered when the UI representation of a notification mode is changed. - */ - fun onModeChange(position: Int, @NotificationMode mode: Int) - } -} - -data class SubscriptionItem( - val id: Long, - val title: String, - @NotificationMode - val notificationMode: Int, - val serviceId: Int, - val url: String -) diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt deleted file mode 100644 index 9dbfa826a..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.schabi.newpipe.settings.notifications - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers -import org.schabi.newpipe.R -import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.databinding.FragmentChannelsNotificationsBinding -import org.schabi.newpipe.local.subscription.SubscriptionManager - -/** - * [NotificationModeConfigFragment] is a settings fragment - * which allows changing the [NotificationMode] of all subscribed channels. - * The [NotificationMode] can either be changed one by one or toggled for all channels. - */ -class NotificationModeConfigFragment : Fragment() { - private var _binding: FragmentChannelsNotificationsBinding? = null - private val binding get() = _binding!! - - private val disposables = CompositeDisposable() - private var loader: Disposable? = null - private lateinit var adapter: NotificationModeConfigAdapter - private lateinit var subscriptionManager: SubscriptionManager - - override fun onAttach(context: Context) { - super.onAttach(context) - subscriptionManager = SubscriptionManager(context) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentChannelsNotificationsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - adapter = NotificationModeConfigAdapter { position, mode -> - // Notification mode has been changed via the UI. - // Now change it in the database. - updateNotificationMode(adapter.currentList[position], mode) - } - binding.recyclerView.adapter = adapter - loader?.dispose() - loader = subscriptionManager.subscriptions() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(adapter::update) - } - - override fun onDestroyView() { - loader?.dispose() - loader = null - _binding = null - super.onDestroyView() - } - - override fun onDestroy() { - disposables.dispose() - super.onDestroy() - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.menu_notifications_channels, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_toggle_all -> { - toggleAll() - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - private fun toggleAll() { - val mode = adapter.currentList.firstOrNull()?.notificationMode ?: return - val newMode = when (mode) { - NotificationMode.DISABLED -> NotificationMode.ENABLED - else -> NotificationMode.DISABLED - } - adapter.currentList.forEach { updateNotificationMode(it, newMode) } - } - - private fun updateNotificationMode(item: SubscriptionItem, @NotificationMode mode: Int) { - disposables.add( - subscriptionManager.updateNotificationMode(item.serviceId, item.url, mode) - .subscribeOn(Schedulers.io()) - .subscribe() - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java deleted file mode 100644 index 68b0010c4..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.text.TextUtils; -import android.util.Pair; - -import org.apache.commons.text.similarity.FuzzyScore; - -import java.util.Comparator; -import java.util.Locale; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class PreferenceFuzzySearchFunction - implements PreferenceSearchConfiguration.PreferenceSearchFunction { - - private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT); - - @Override - public Stream search( - final Stream allAvailable, - final String keyword - ) { - final int maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score - - return allAvailable - // General search - // Check all fields if anyone contains something that kind of matches the keyword - .map(item -> new FuzzySearchGeneralDTO(item, keyword)) - .filter(dto -> dto.getScore() / maxScore >= 0.3f) - .map(FuzzySearchGeneralDTO::getItem) - // Specific search - Used for determining order of search results - // Calculate a score based on specific search fields - .map(item -> new FuzzySearchSpecificDTO(item, keyword)) - .sorted(Comparator.comparingDouble(FuzzySearchSpecificDTO::getScore).reversed()) - .map(FuzzySearchSpecificDTO::getItem) - // Limit the amount of search results - .limit(20); - } - - static class FuzzySearchGeneralDTO { - private final PreferenceSearchItem item; - private final float score; - - FuzzySearchGeneralDTO( - final PreferenceSearchItem item, - final String keyword) { - this.item = item; - this.score = FUZZY_SCORE.fuzzyScore( - TextUtils.join(";", item.getAllRelevantSearchFields()), - keyword); - } - - public PreferenceSearchItem getItem() { - return item; - } - - public float getScore() { - return score; - } - } - - static class FuzzySearchSpecificDTO { - private static final Map, Float> WEIGHT_MAP = Map.of( - // The user will most likely look for the title -> prioritize it - PreferenceSearchItem::getTitle, 1.5f, - // The summary is also important as it usually contains a larger desc - // Example: Searching for '4k' → 'show higher resolution' is shown - PreferenceSearchItem::getSummary, 1f, - // Entries are also important as they provide all known/possible values - // Example: Searching where the resolution can be changed to 720p - PreferenceSearchItem::getEntries, 1f - ); - - private final PreferenceSearchItem item; - private final double score; - - FuzzySearchSpecificDTO(final PreferenceSearchItem item, final String keyword) { - this.item = item; - this.score = WEIGHT_MAP.entrySet().stream() - .map(entry -> new Pair<>(entry.getKey().apply(item), entry.getValue())) - .filter(pair -> !pair.first.isEmpty()) - .collect(Collectors.averagingDouble(pair -> - FUZZY_SCORE.fuzzyScore(pair.first, keyword) * pair.second)); - } - - public PreferenceSearchItem getItem() { - return item; - } - - public double getScore() { - return score; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java deleted file mode 100644 index b925e8b5f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.content.Context; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.XmlRes; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.util.Localization; -import org.xmlpull.v1.XmlPullParser; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -/** - * Parses the corresponding preference-file(s). - */ -public class PreferenceParser { - private static final String TAG = "PreferenceParser"; - - private static final String NS_ANDROID = "http://schemas.android.com/apk/res/android"; - private static final String NS_SEARCH = "http://schemas.android.com/apk/preferencesearch"; - - private final Context context; - private final Map allPreferences; - private final PreferenceSearchConfiguration searchConfiguration; - - public PreferenceParser( - final Context context, - final PreferenceSearchConfiguration searchConfiguration - ) { - this.context = context; - this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll(); - this.searchConfiguration = searchConfiguration; - } - - public List parse( - @XmlRes final int resId - ) { - final List results = new ArrayList<>(); - final XmlPullParser xpp = context.getResources().getXml(resId); - - try { - xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); - xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true); - - final List breadcrumbs = new ArrayList<>(); - while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) { - if (xpp.getEventType() == XmlPullParser.START_TAG) { - final PreferenceSearchItem result = parseSearchResult( - xpp, - Localization.concatenateStrings(" > ", breadcrumbs), - resId - ); - - if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName()) - && result.hasData() - && !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) { - results.add(result); - } - if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) { - // This code adds breadcrumbs for certain containers (e.g. PreferenceScreen) - // Example: Video and Audio > Player - breadcrumbs.add(result.getTitle() == null ? "" : result.getTitle()); - } - } else if (xpp.getEventType() == XmlPullParser.END_TAG - && searchConfiguration.getParserContainerElements() - .contains(xpp.getName())) { - breadcrumbs.remove(breadcrumbs.size() - 1); - } - - xpp.next(); - } - } catch (final Exception e) { - Log.w(TAG, "Failed to parse resid=" + resId, e); - } - return results; - } - - private String getAttribute( - final XmlPullParser xpp, - @NonNull final String attribute - ) { - final String nsSearchAttr = getAttribute(xpp, NS_SEARCH, attribute); - if (nsSearchAttr != null) { - return nsSearchAttr; - } - return getAttribute(xpp, NS_ANDROID, attribute); - } - - private String getAttribute( - final XmlPullParser xpp, - @NonNull final String namespace, - @NonNull final String attribute - ) { - return xpp.getAttributeValue(namespace, attribute); - } - - private PreferenceSearchItem parseSearchResult( - final XmlPullParser xpp, - final String breadcrumbs, - @XmlRes final int searchIndexItemResId - ) { - final String key = readString(getAttribute(xpp, "key")); - final String[] entries = readStringArray(getAttribute(xpp, "entries")); - final String[] entryValues = readStringArray(getAttribute(xpp, "entryValues")); - - return new PreferenceSearchItem( - key, - tryFillInPreferenceValue( - readString(getAttribute(xpp, "title")), - key, - entries, - entryValues), - tryFillInPreferenceValue( - readString(getAttribute(xpp, "summary")), - key, - entries, - entryValues), - TextUtils.join(",", entries), - breadcrumbs, - searchIndexItemResId - ); - } - - private String[] readStringArray(@Nullable final String s) { - if (s == null) { - return new String[0]; - } - if (s.startsWith("@")) { - try { - return context.getResources().getStringArray(Integer.parseInt(s.substring(1))); - } catch (final Exception e) { - Log.w(TAG, "Unable to readStringArray from '" + s + "'", e); - } - } - return new String[0]; - } - - private String readString(@Nullable final String s) { - if (s == null) { - return ""; - } - if (s.startsWith("@")) { - try { - return context.getString(Integer.parseInt(s.substring(1))); - } catch (final Exception e) { - Log.w(TAG, "Unable to readString from '" + s + "'", e); - } - } - return s; - } - - private String tryFillInPreferenceValue( - @Nullable final String s, - @Nullable final String key, - final String[] entries, - final String[] entryValues - ) { - if (s == null) { - return ""; - } - if (key == null) { - return s; - } - - // Resolve value - Object prefValue = allPreferences.get(key); - if (prefValue == null) { - return s; - } - - /* - * Resolve ListPreference values - * - * entryValues = Values/Keys that are saved - * entries = Actual human readable names - */ - if (entries.length > 0 && entryValues.length == entries.length) { - final int entryIndex = Arrays.asList(entryValues).indexOf(prefValue); - if (entryIndex != -1) { - prefValue = entries[entryIndex]; - } - } - - return String.format(s, prefValue.toString()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java deleted file mode 100644 index dd59ba86e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ListAdapter; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.databinding.SettingsPreferencesearchListItemResultBinding; - -import java.util.function.Consumer; - -class PreferenceSearchAdapter - extends ListAdapter { - private Consumer onItemClickListener; - - PreferenceSearchAdapter() { - super(new PreferenceCallback()); - } - - @NonNull - @Override - public PreferenceViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - return new PreferenceViewHolder(SettingsPreferencesearchListItemResultBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull final PreferenceViewHolder holder, final int position) { - final PreferenceSearchItem item = getItem(position); - - holder.binding.title.setText(item.getTitle()); - - if (item.getSummary().isEmpty()) { - holder.binding.summary.setVisibility(View.GONE); - } else { - holder.binding.summary.setVisibility(View.VISIBLE); - holder.binding.summary.setText(item.getSummary()); - } - - if (item.getBreadcrumbs().isEmpty()) { - holder.binding.breadcrumbs.setVisibility(View.GONE); - } else { - holder.binding.breadcrumbs.setVisibility(View.VISIBLE); - holder.binding.breadcrumbs.setText(item.getBreadcrumbs()); - } - - holder.itemView.setOnClickListener(v -> { - if (onItemClickListener != null) { - onItemClickListener.accept(item); - } - }); - } - - void setOnItemClickListener(final Consumer onItemClickListener) { - this.onItemClickListener = onItemClickListener; - } - - static class PreferenceViewHolder extends RecyclerView.ViewHolder { - final SettingsPreferencesearchListItemResultBinding binding; - - PreferenceViewHolder(final SettingsPreferencesearchListItemResultBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - - private static final class PreferenceCallback - extends DiffUtil.ItemCallback { - @Override - public boolean areItemsTheSame(@NonNull final PreferenceSearchItem oldItem, - @NonNull final PreferenceSearchItem newItem) { - return oldItem.getKey().equals(newItem.getKey()); - } - - @Override - public boolean areContentsTheSame(@NonNull final PreferenceSearchItem oldItem, - @NonNull final PreferenceSearchItem newItem) { - return oldItem.getAllRelevantSearchFields().equals(newItem - .getAllRelevantSearchFields()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java deleted file mode 100644 index 1ded181c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceScreen; - -import java.util.List; -import java.util.Objects; -import java.util.stream.Stream; - -public class PreferenceSearchConfiguration { - private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); - - private final List parserIgnoreElements = List.of( - PreferenceCategory.class.getSimpleName()); - private final List parserContainerElements = List.of( - PreferenceCategory.class.getSimpleName(), - PreferenceScreen.class.getSimpleName()); - - - public void setSearcher(final PreferenceSearchFunction searcher) { - this.searcher = Objects.requireNonNull(searcher); - } - - public PreferenceSearchFunction getSearcher() { - return searcher; - } - - public List getParserIgnoreElements() { - return parserIgnoreElements; - } - - public List getParserContainerElements() { - return parserContainerElements; - } - - @FunctionalInterface - public interface PreferenceSearchFunction { - Stream search( - Stream allAvailable, - String keyword); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java deleted file mode 100644 index 9d169d660..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; - -import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding; - -import java.util.List; - -/** - * Displays the search results. - */ -public class PreferenceSearchFragment extends Fragment { - public static final String NAME = PreferenceSearchFragment.class.getSimpleName(); - - private PreferenceSearcher searcher; - - private SettingsPreferencesearchFragmentBinding binding; - private PreferenceSearchAdapter adapter; - - public void setSearcher(final PreferenceSearcher searcher) { - this.searcher = searcher; - } - - @Nullable - @Override - public View onCreateView( - @NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState - ) { - binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false); - - binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext())); - - adapter = new PreferenceSearchAdapter(); - adapter.setOnItemClickListener(this::onItemClicked); - binding.searchResults.setAdapter(adapter); - - return binding.getRoot(); - } - - public void updateSearchResults(final String keyword) { - if (adapter == null || searcher == null) { - return; - } - - final List results = searcher.searchFor(keyword); - adapter.submitList(results); - setEmptyViewShown(results.isEmpty()); - } - - private void setEmptyViewShown(final boolean shown) { - binding.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE); - binding.searchResults.setVisibility(shown ? View.GONE : View.VISIBLE); - } - - public void onItemClicked(final PreferenceSearchItem item) { - if (!(getActivity() instanceof PreferenceSearchResultListener)) { - throw new ClassCastException( - getActivity().toString() + " must implement SearchPreferenceResultListener"); - } - - ((PreferenceSearchResultListener) getActivity()).onSearchResultClicked(item); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt deleted file mode 100644 index 82bbe7b23..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings.preferencesearch - -import androidx.annotation.XmlRes - -/** - * Represents a preference-item inside the search. - * - * @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences]. - * @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'. - * @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'. - * @param entries Possible entries of the setting, e.g. 480p,720p,... - * @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player' - * @param searchIndexItemResId The xml-resource where this item was found/built from. - */ - -data class PreferenceSearchItem( - val key: String, - val title: String, - val summary: String, - val entries: String, - val breadcrumbs: String, - @XmlRes val searchIndexItemResId: Int -) { - val allRelevantSearchFields: List - get() = listOf(title, summary, entries, breadcrumbs) - - fun hasData(): Boolean { - return !key.isEmpty() && !title.isEmpty() - } - - override fun toString(): String { - return "PreferenceItem: $title $summary $key" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java deleted file mode 100644 index 7eae5c128..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.RippleDrawable; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; -import android.util.TypedValue; - -import androidx.appcompat.content.res.AppCompatResources; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceGroup; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.R; - - -public final class PreferenceSearchResultHighlighter { - private static final String TAG = "PrefSearchResHighlter"; - - private PreferenceSearchResultHighlighter() { - } - - /** - * Highlight the specified preference. - *
- * Note: This function is Thread independent (can be called from outside of the main thread). - * - * @param item The item to highlight - * @param prefsFragment The fragment where the items is located on - */ - public static void highlight( - final PreferenceSearchItem item, - final PreferenceFragmentCompat prefsFragment - ) { - new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment)); - } - - private static void doHighlight( - final PreferenceSearchItem item, - final PreferenceFragmentCompat prefsFragment - ) { - final Preference prefResult = prefsFragment.findPreference(item.getKey()); - - if (prefResult == null) { - Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'"); - return; - } - - final RecyclerView recyclerView = prefsFragment.getListView(); - final RecyclerView.Adapter adapter = recyclerView.getAdapter(); - if (adapter instanceof PreferenceGroup.PreferencePositionCallback) { - final int position = ((PreferenceGroup.PreferencePositionCallback) adapter) - .getPreferenceAdapterPosition(prefResult); - if (position != RecyclerView.NO_POSITION) { - recyclerView.scrollToPosition(position); - recyclerView.postDelayed(() -> { - final RecyclerView.ViewHolder holder = - recyclerView.findViewHolderForAdapterPosition(position); - if (holder != null) { - final Drawable background = holder.itemView.getBackground(); - if (background instanceof RippleDrawable) { - showRippleAnimation((RippleDrawable) background); - return; - } - } - highlightFallback(prefsFragment, prefResult); - }, 200); - return; - } - } - highlightFallback(prefsFragment, prefResult); - } - - /** - * Alternative highlighting (shows an → arrow in front of the setting)if ripple does not work. - * - * @param prefsFragment - * @param prefResult - */ - private static void highlightFallback( - final PreferenceFragmentCompat prefsFragment, - final Preference prefResult - ) { - // Get primary color from text for highlight icon - final TypedValue typedValue = new TypedValue(); - final Resources.Theme theme = prefsFragment.getActivity().getTheme(); - theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true); - final TypedArray arr = prefsFragment.getActivity() - .obtainStyledAttributes( - typedValue.data, - new int[]{android.R.attr.textColorPrimary}); - final int color = arr.getColor(0, 0xffE53935); - arr.recycle(); - - // Show highlight icon - final Drawable oldIcon = prefResult.getIcon(); - final boolean oldSpaceReserved = prefResult.isIconSpaceReserved(); - final Drawable highlightIcon = - AppCompatResources.getDrawable( - prefsFragment.requireContext(), - R.drawable.ic_play_arrow); - highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); - prefResult.setIcon(highlightIcon); - - prefsFragment.scrollToPreference(prefResult); - - new Handler(Looper.getMainLooper()).postDelayed(() -> { - prefResult.setIcon(oldIcon); - prefResult.setIconSpaceReserved(oldSpaceReserved); - }, 1000); - } - - private static void showRippleAnimation(final RippleDrawable rippleDrawable) { - rippleDrawable.setState( - new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled}); - new Handler(Looper.getMainLooper()) - .postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt deleted file mode 100644 index 7b7b7884a..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings.preferencesearch - -interface PreferenceSearchResultListener { - fun onSearchResultClicked(result: PreferenceSearchItem) -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java deleted file mode 100644 index b3efc8dd1..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -public class PreferenceSearcher { - private final List allEntries = new ArrayList<>(); - - private final PreferenceSearchConfiguration configuration; - - public PreferenceSearcher(final PreferenceSearchConfiguration configuration) { - this.configuration = configuration; - } - - public void add(final List items) { - allEntries.addAll(items); - } - - List searchFor(final String keyword) { - if (TextUtils.isEmpty(keyword)) { - return Collections.emptyList(); - } - - return configuration.getSearcher() - .search(allEntries.stream(), keyword) - .collect(Collectors.toList()); - } - - public void clear() { - allEntries.clear(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java deleted file mode 100644 index 00929235e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Contains classes for searching inside the preferences. - *
- * This code is based on - * ByteHamster/SearchPreference - * (MIT license) but was heavily modified/refactored for our use. - * - * @author litetex - */ -package org.schabi.newpipe.settings.preferencesearch; diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java deleted file mode 100644 index e2e833fee..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.schabi.newpipe.settings.tabs; - -import android.content.Context; -import android.content.DialogInterface; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.TextView; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.AppCompatImageView; - -import org.schabi.newpipe.R; - -public final class AddTabDialog { - private final AlertDialog dialog; - - AddTabDialog(@NonNull final Context context, @NonNull final ChooseTabListItem[] items, - @NonNull final DialogInterface.OnClickListener actions) { - - dialog = new AlertDialog.Builder(context) - .setTitle(context.getString(R.string.tab_choose)) - .setAdapter(new DialogListAdapter(context, items), actions) - .create(); - } - - public void show() { - dialog.show(); - } - - static final class ChooseTabListItem { - final int tabId; - final String itemName; - @DrawableRes - final int itemIcon; - - ChooseTabListItem(final Context context, final Tab tab) { - this(tab.getTabId(), tab.getTabName(context), tab.getTabIconRes(context)); - } - - ChooseTabListItem(final int tabId, final String itemName, - @DrawableRes final int itemIcon) { - this.tabId = tabId; - this.itemName = itemName; - this.itemIcon = itemIcon; - } - } - - private static final class DialogListAdapter extends BaseAdapter { - private final LayoutInflater inflater; - private final ChooseTabListItem[] items; - - @DrawableRes - private final int fallbackIcon; - - private DialogListAdapter(final Context context, final ChooseTabListItem[] items) { - this.inflater = LayoutInflater.from(context); - this.items = items; - this.fallbackIcon = R.drawable.ic_whatshot; - } - - @Override - public int getCount() { - return items.length; - } - - @Override - public ChooseTabListItem getItem(final int position) { - return items[position]; - } - - @Override - public long getItemId(final int position) { - return getItem(position).tabId; - } - - @Override - public View getView(final int position, final View view, final ViewGroup parent) { - View convertView = view; - if (convertView == null) { - convertView = inflater.inflate(R.layout.list_choose_tabs_dialog, parent, false); - } - - final ChooseTabListItem item = getItem(position); - final AppCompatImageView tabIconView = convertView.findViewById(R.id.tabIcon); - final TextView tabNameView = convertView.findViewById(R.id.tabName); - - tabIconView.setImageResource(item.itemIcon > 0 ? item.itemIcon : fallbackIcon); - tabNameView.setText(item.itemName); - - return convertView; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java deleted file mode 100644 index 738a9c926..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ /dev/null @@ -1,435 +0,0 @@ -package org.schabi.newpipe.settings.tabs; - -import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; -import static org.schabi.newpipe.util.ServiceHelper.getNameOfServiceById; - -import android.annotation.SuppressLint; -import android.app.Dialog; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.AppCompatImageView; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.settings.SelectChannelFragment; -import org.schabi.newpipe.settings.SelectKioskFragment; -import org.schabi.newpipe.settings.SelectPlaylistFragment; -import org.schabi.newpipe.settings.SelectFeedGroupFragment; -import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem; -import org.schabi.newpipe.util.ThemeHelper; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class ChooseTabsFragment extends Fragment { - private TabsManager tabsManager; - - private final List tabList = new ArrayList<>(); - private ChooseTabsFragment.SelectedTabsAdapter selectedTabsAdapter; - - /*////////////////////////////////////////////////////////////////////////// - // Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - tabsManager = TabsManager.getManager(requireContext()); - updateTabList(); - - setHasOptionsMenu(true); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_choose_tabs, container, false); - } - - @Override - public void onViewCreated(@NonNull final View rootView, - @Nullable final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - - initButton(rootView); - - final RecyclerView listSelectedTabs = rootView.findViewById(R.id.selectedTabs); - listSelectedTabs.setLayoutManager(new LinearLayoutManager(requireContext())); - - final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(listSelectedTabs); - - selectedTabsAdapter = new SelectedTabsAdapter(requireContext(), itemTouchHelper); - listSelectedTabs.setAdapter(selectedTabsAdapter); - } - - @Override - public void onResume() { - super.onResume(); - ThemeHelper.setTitleToAppCompatActivity(getActivity(), - getString(R.string.main_page_content)); - } - - @Override - public void onPause() { - super.onPause(); - saveChanges(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_chooser_fragment, menu); - menu.findItem(R.id.menu_item_restore_default).setOnMenuItemClickListener(item -> { - restoreDefaults(); - return true; - }); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void updateTabList() { - tabList.clear(); - tabList.addAll(tabsManager.getTabs()); - } - - private void saveChanges() { - tabsManager.saveTabs(tabList); - } - - private void restoreDefaults() { - new AlertDialog.Builder(requireContext()) - .setTitle(R.string.restore_defaults) - .setMessage(R.string.restore_defaults_confirmation) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> { - tabsManager.resetTabs(); - updateTabList(); - selectedTabsAdapter.notifyDataSetChanged(); - }) - .show(); - } - - private void initButton(final View rootView) { - final FloatingActionButton fab = rootView.findViewById(R.id.addTabsButton); - fab.setOnClickListener(v -> { - final ChooseTabListItem[] availableTabs = getAvailableTabs(requireContext()); - - if (availableTabs.length == 0) { - //Toast.makeText(requireContext(), "No available tabs", Toast.LENGTH_SHORT).show(); - return; - } - - final Dialog.OnClickListener actionListener = (dialog, which) -> { - final ChooseTabListItem selected = availableTabs[which]; - addTab(selected.tabId); - }; - - new AddTabDialog(requireContext(), availableTabs, actionListener) - .show(); - }); - } - - private void addTab(final Tab tab) { - tabList.add(tab); - selectedTabsAdapter.notifyDataSetChanged(); - } - - private void addTab(final int tabId) { - final Tab.Type type = typeFrom(tabId); - - if (type == null) { - ErrorUtil.showSnackbar(this, - new ErrorInfo(new IllegalStateException("Tab id not found: " + tabId), - UserAction.SOMETHING_ELSE, "Choosing tabs on settings")); - return; - } - - switch (type) { - case KIOSK: - final SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); - selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> - addTab(new Tab.KioskTab(serviceId, kioskId))); - selectKioskFragment.show(getParentFragmentManager(), "select_kiosk"); - return; - case CHANNEL: - final SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); - selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> - addTab(new Tab.ChannelTab(serviceId, url, name))); - selectChannelFragment.show(getParentFragmentManager(), "select_channel"); - return; - case PLAYLIST: - final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); - selectPlaylistFragment.setOnSelectedListener( - new SelectPlaylistFragment.OnSelectedListener() { - @Override - public void onLocalPlaylistSelected(final long id, final String name) { - addTab(new Tab.PlaylistTab(id, name)); - } - - @Override - public void onRemotePlaylistSelected( - final int serviceId, final String url, final String name) { - addTab(new Tab.PlaylistTab(serviceId, url, name)); - } - }); - selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist"); - return; - case FEEDGROUP: - final SelectFeedGroupFragment selectFeedGroupFragment = - new SelectFeedGroupFragment(); - selectFeedGroupFragment.setOnSelectedListener( - (groupId, name, iconId) -> - addTab(new Tab.FeedGroupTab(groupId, name, iconId))); - selectFeedGroupFragment.show(getParentFragmentManager(), "select_feed_group"); - return; - default: - addTab(type.getTab()); - break; - } - } - - private ChooseTabListItem[] getAvailableTabs(final Context context) { - final ArrayList returnList = new ArrayList<>(); - - for (final Tab.Type type : Tab.Type.values()) { - final Tab tab = type.getTab(); - switch (type) { - case BLANK: - if (!tabList.contains(tab)) { - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.blank_page_summary), - tab.getTabIconRes(context))); - } - break; - case KIOSK: - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.kiosk_page_summary), - R.drawable.ic_whatshot)); - break; - case CHANNEL: - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.channel_page_summary), - tab.getTabIconRes(context))); - break; - case DEFAULT_KIOSK: - if (!tabList.contains(tab)) { - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.default_kiosk_page_summary), - R.drawable.ic_whatshot)); - } - break; - case PLAYLIST: - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.playlist_page_summary), - tab.getTabIconRes(context))); - break; - case FEEDGROUP: - returnList.add(new ChooseTabListItem(tab.getTabId(), - getString(R.string.feed_group_page_summary), - tab.getTabIconRes(context))); - break; - default: - if (!tabList.contains(tab)) { - returnList.add(new ChooseTabListItem(context, tab)); - } - break; - } - } - - return returnList.toArray(new ChooseTabListItem[0]); - } - - /*////////////////////////////////////////////////////////////////////////// - // List Handling - //////////////////////////////////////////////////////////////////////////*/ - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, - ItemTouchHelper.START | ItemTouchHelper.END) { - @Override - public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, - final int viewSize, - final int viewSizeOutOfBounds, - final int totalSize, - final long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, - viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int minimumAbsVelocity = Math.max(12, - Math.abs(standardSpeed)); - return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(@NonNull final RecyclerView recyclerView, - @NonNull final RecyclerView.ViewHolder source, - @NonNull final RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType() - || selectedTabsAdapter == null) { - return false; - } - - final int sourceIndex = source.getBindingAdapterPosition(); - final int targetIndex = target.getBindingAdapterPosition(); - selectedTabsAdapter.swapItems(sourceIndex, targetIndex); - return true; - } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return true; - } - - @Override - public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, - final int swipeDir) { - final int position = viewHolder.getBindingAdapterPosition(); - tabList.remove(position); - selectedTabsAdapter.notifyItemRemoved(position); - - if (tabList.isEmpty()) { - tabList.add(Tab.Type.BLANK.getTab()); - selectedTabsAdapter.notifyItemInserted(0); - } - } - }; - } - - private class SelectedTabsAdapter - extends RecyclerView.Adapter { - private final LayoutInflater inflater; - private final ItemTouchHelper itemTouchHelper; - - SelectedTabsAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { - this.itemTouchHelper = itemTouchHelper; - this.inflater = LayoutInflater.from(context); - } - - public void swapItems(final int fromPosition, final int toPosition) { - Collections.swap(tabList, fromPosition, toPosition); - notifyItemMoved(fromPosition, toPosition); - } - - @NonNull - @Override - public ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder onCreateViewHolder( - @NonNull final ViewGroup parent, final int viewType) { - final View view = inflater.inflate(R.layout.list_choose_tabs, parent, false); - return new ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder(view); - } - - @Override - public void onBindViewHolder( - @NonNull final ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder holder, - final int position) { - holder.bind(position, holder); - } - - @Override - public int getItemCount() { - return tabList.size(); - } - - class TabViewHolder extends RecyclerView.ViewHolder { - private final AppCompatImageView tabIconView; - private final TextView tabNameView; - private final ImageView handle; - - TabViewHolder(final View itemView) { - super(itemView); - - tabNameView = itemView.findViewById(R.id.tabName); - tabIconView = itemView.findViewById(R.id.tabIcon); - handle = itemView.findViewById(R.id.handle); - } - - @SuppressLint("ClickableViewAccessibility") - void bind(final int position, final TabViewHolder holder) { - handle.setOnTouchListener(getOnTouchListener(holder)); - - final Tab tab = tabList.get(position); - final Tab.Type type = Tab.typeFrom(tab.getTabId()); - - if (type == null) { - return; - } - - tabNameView.setText(getTabName(type, tab)); - tabIconView.setImageResource(tab.getTabIconRes(requireContext())); - } - - private String getTabName(@NonNull final Tab.Type type, @NonNull final Tab tab) { - switch (type) { - case BLANK: - return getString(R.string.blank_page_summary); - case DEFAULT_KIOSK: - return getString(R.string.default_kiosk_page_summary); - case KIOSK: - return getNameOfServiceById(((Tab.KioskTab) tab).getKioskServiceId()) - + "/" + tab.getTabName(requireContext()); - case CHANNEL: - return getNameOfServiceById(((Tab.ChannelTab) tab).getChannelServiceId()) - + "/" + tab.getTabName(requireContext()); - case PLAYLIST: - final int serviceId = ((Tab.PlaylistTab) tab).getPlaylistServiceId(); - final String serviceName = serviceId == -1 - ? getString(R.string.local) - : getNameOfServiceById(serviceId); - return serviceName + "/" + tab.getTabName(requireContext()); - case FEEDGROUP: - return getString(R.string.feed_groups_header_title) - + "/" + ((Tab.FeedGroupTab) tab).getFeedGroupName(); - default: - return tab.getTabName(requireContext()); - } - } - - @SuppressLint("ClickableViewAccessibility") - private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { - return (view, motionEvent) -> { - if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - if (itemTouchHelper != null && getItemCount() > 1) { - itemTouchHelper.startDrag(item); - return true; - } - } - return false; - }; - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java deleted file mode 100644 index 4c1f65df2..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ /dev/null @@ -1,747 +0,0 @@ -package org.schabi.newpipe.settings.tabs; - -import android.content.Context; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonStringWriter; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem.LocalItemType; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.fragments.BlankFragment; -import org.schabi.newpipe.fragments.list.channel.ChannelFragment; -import org.schabi.newpipe.fragments.list.kiosk.DefaultKioskFragment; -import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; -import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; -import org.schabi.newpipe.local.bookmark.BookmarkFragment; -import org.schabi.newpipe.local.feed.FeedFragment; -import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; -import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; -import org.schabi.newpipe.local.subscription.SubscriptionFragment; -import org.schabi.newpipe.util.KioskTranslator; -import org.schabi.newpipe.util.ServiceHelper; - -import java.util.Objects; - -public abstract class Tab { - private static final String JSON_TAB_ID_KEY = "tab_id"; - - private static final String NO_NAME = ""; - private static final String NO_ID = ""; - private static final String NO_URL = ""; - - Tab() { - } - - Tab(@NonNull final JsonObject jsonObject) { - readDataFromJson(jsonObject); - } - - /*////////////////////////////////////////////////////////////////////////// - // Tab Handling - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - public static Tab from(@NonNull final JsonObject jsonObject) { - final int tabId = jsonObject.getInt(Tab.JSON_TAB_ID_KEY, -1); - - if (tabId == -1) { - return null; - } - - return from(tabId, jsonObject); - } - - @Nullable - public static Tab from(final int tabId) { - return from(tabId, null); - } - - @Nullable - public static Type typeFrom(final int tabId) { - for (final Type available : Type.values()) { - if (available.getTabId() == tabId) { - return available; - } - } - return null; - } - - @Nullable - private static Tab from(final int tabId, @Nullable final JsonObject jsonObject) { - final Type type = typeFrom(tabId); - - if (type == null) { - return null; - } - - if (jsonObject != null) { - switch (type) { - case KIOSK: - return new KioskTab(jsonObject); - case CHANNEL: - return new ChannelTab(jsonObject); - case PLAYLIST: - return new PlaylistTab(jsonObject); - case FEEDGROUP: - return new FeedGroupTab(jsonObject); - } - } - - return type.getTab(); - } - - public abstract int getTabId(); - - public abstract String getTabName(Context context); - - @DrawableRes - public abstract int getTabIconRes(Context context); - - /** - * Return a instance of the fragment that this tab represent. - * - * @param context Android app context - * @return the fragment this tab represents - */ - public abstract Fragment getFragment(Context context) throws ExtractionException; - - @Override - public boolean equals(final Object obj) { - if (!(obj instanceof Tab)) { - return false; - } - final Tab other = (Tab) obj; - return getTabId() == other.getTabId(); - } - - @Override - public int hashCode() { - return Objects.hashCode(getTabId()); - } - - /*////////////////////////////////////////////////////////////////////////// - // JSON Handling - //////////////////////////////////////////////////////////////////////////*/ - - public void writeJsonOn(final JsonStringWriter jsonSink) { - jsonSink.object(); - - jsonSink.value(JSON_TAB_ID_KEY, getTabId()); - writeDataToJson(jsonSink); - - jsonSink.end(); - } - - protected void writeDataToJson(final JsonStringWriter writerSink) { - // No-op - } - - protected void readDataFromJson(final JsonObject jsonObject) { - // No-op - } - - /*////////////////////////////////////////////////////////////////////////// - // Implementations - //////////////////////////////////////////////////////////////////////////*/ - - public enum Type { - BLANK(new BlankTab()), - DEFAULT_KIOSK(new DefaultKioskTab()), - SUBSCRIPTIONS(new SubscriptionsTab()), - FEED(new FeedTab()), - BOOKMARKS(new BookmarksTab()), - HISTORY(new HistoryTab()), - KIOSK(new KioskTab()), - CHANNEL(new ChannelTab()), - PLAYLIST(new PlaylistTab()), - FEEDGROUP(new FeedGroupTab()); - - private final Tab tab; - - Type(final Tab tab) { - this.tab = tab; - } - - public int getTabId() { - return tab.getTabId(); - } - - public Tab getTab() { - return tab; - } - } - - public static class BlankTab extends Tab { - public static final int ID = 0; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - // TODO: find a better name for the blank tab (maybe "blank_tab") or replace it with - // context.getString(R.string.app_name); - return "NewPipe"; // context.getString(R.string.blank_page_summary); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_crop_portrait; - } - - @Override - public BlankFragment getFragment(final Context context) { - return new BlankFragment(); - } - } - - public static class SubscriptionsTab extends Tab { - public static final int ID = 1; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return context.getString(R.string.tab_subscriptions); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_tv; - } - - @Override - public SubscriptionFragment getFragment(final Context context) { - return new SubscriptionFragment(); - } - - } - - public static class FeedTab extends Tab { - public static final int ID = 2; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return context.getString(R.string.fragment_feed_title); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_subscriptions; - } - - @Override - public FeedFragment getFragment(final Context context) { - return new FeedFragment(); - } - } - - public static class BookmarksTab extends Tab { - public static final int ID = 3; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return context.getString(R.string.tab_bookmarks); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_bookmark; - } - - @Override - public BookmarkFragment getFragment(final Context context) { - return new BookmarkFragment(); - } - } - - public static class HistoryTab extends Tab { - public static final int ID = 4; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return context.getString(R.string.title_activity_history); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_history; - } - - @Override - public StatisticsPlaylistFragment getFragment(final Context context) { - return new StatisticsPlaylistFragment(); - } - } - - public static class KioskTab extends Tab { - public static final int ID = 5; - private static final String JSON_KIOSK_SERVICE_ID_KEY = "service_id"; - private static final String JSON_KIOSK_ID_KEY = "kiosk_id"; - private int kioskServiceId; - private String kioskId; - - private KioskTab() { - this(-1, NO_ID); - } - - public KioskTab(final int kioskServiceId, final String kioskId) { - this.kioskServiceId = kioskServiceId; - this.kioskId = kioskId; - } - - public KioskTab(final JsonObject jsonObject) { - super(jsonObject); - } - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return KioskTranslator.getTranslatedKioskName(kioskId, context); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - final int kioskIcon = KioskTranslator.getKioskIcon(kioskId); - - if (kioskIcon <= 0) { - throw new IllegalStateException("Kiosk ID is not valid: \"" + kioskId + "\""); - } - - return kioskIcon; - } - - @Override - public KioskFragment getFragment(final Context context) throws ExtractionException { - return KioskFragment.getInstance(kioskServiceId, kioskId); - } - - @Override - protected void writeDataToJson(final JsonStringWriter writerSink) { - writerSink.value(JSON_KIOSK_SERVICE_ID_KEY, kioskServiceId) - .value(JSON_KIOSK_ID_KEY, kioskId); - } - - @Override - protected void readDataFromJson(final JsonObject jsonObject) { - kioskServiceId = jsonObject.getInt(JSON_KIOSK_SERVICE_ID_KEY, -1); - kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, NO_ID); - } - - @Override - public boolean equals(final Object obj) { - if (!(obj instanceof KioskTab)) { - return false; - } - final KioskTab other = (KioskTab) obj; - return super.equals(obj) - && kioskServiceId == other.kioskServiceId - && kioskId.equals(other.kioskId); - } - - @Override - public int hashCode() { - return Objects.hash(getTabId(), kioskServiceId, kioskId); - } - - public int getKioskServiceId() { - return kioskServiceId; - } - - public String getKioskId() { - return kioskId; - } - } - - public static class ChannelTab extends Tab { - public static final int ID = 6; - private static final String JSON_CHANNEL_SERVICE_ID_KEY = "channel_service_id"; - private static final String JSON_CHANNEL_URL_KEY = "channel_url"; - private static final String JSON_CHANNEL_NAME_KEY = "channel_name"; - private int channelServiceId; - private String channelUrl; - private String channelName; - - private ChannelTab() { - this(-1, NO_URL, NO_NAME); - } - - public ChannelTab(final int channelServiceId, final String channelUrl, - final String channelName) { - this.channelServiceId = channelServiceId; - this.channelUrl = channelUrl; - this.channelName = channelName; - } - - public ChannelTab(final JsonObject jsonObject) { - super(jsonObject); - } - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return channelName; - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_tv; - } - - @Override - public ChannelFragment getFragment(final Context context) { - return ChannelFragment.getInstance(channelServiceId, channelUrl, channelName); - } - - @Override - protected void writeDataToJson(final JsonStringWriter writerSink) { - writerSink.value(JSON_CHANNEL_SERVICE_ID_KEY, channelServiceId) - .value(JSON_CHANNEL_URL_KEY, channelUrl) - .value(JSON_CHANNEL_NAME_KEY, channelName); - } - - @Override - protected void readDataFromJson(final JsonObject jsonObject) { - channelServiceId = jsonObject.getInt(JSON_CHANNEL_SERVICE_ID_KEY, -1); - channelUrl = jsonObject.getString(JSON_CHANNEL_URL_KEY, NO_URL); - channelName = jsonObject.getString(JSON_CHANNEL_NAME_KEY, NO_NAME); - } - - @Override - public boolean equals(final Object obj) { - if (!(obj instanceof ChannelTab)) { - return false; - } - final ChannelTab other = (ChannelTab) obj; - return super.equals(obj) - && channelServiceId == other.channelServiceId - && channelUrl.equals(other.channelUrl) - && channelName.equals(other.channelName); - } - - @Override - public int hashCode() { - return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName); - } - - public int getChannelServiceId() { - return channelServiceId; - } - - public String getChannelUrl() { - return channelUrl; - } - - public String getChannelName() { - return channelName; - } - } - - public static class DefaultKioskTab extends Tab { - public static final int ID = 7; - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return KioskTranslator.getTranslatedKioskName(getDefaultKioskId(context), context); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return KioskTranslator.getKioskIcon(getDefaultKioskId(context)); - } - - @Override - public DefaultKioskFragment getFragment(final Context context) { - return new DefaultKioskFragment(); - } - - private String getDefaultKioskId(final Context context) { - final int kioskServiceId = ServiceHelper.getSelectedServiceId(context); - - String kioskId = ""; - try { - final StreamingService service = NewPipe.getService(kioskServiceId); - kioskId = service.getKioskList().getDefaultKioskId(); - } catch (final ExtractionException e) { - ErrorUtil.showSnackbar(context, new ErrorInfo(e, - UserAction.REQUESTED_KIOSK, "Loading default kiosk for selected service")); - } - return kioskId; - } - } - - public static class PlaylistTab extends Tab { - public static final int ID = 8; - private static final String JSON_PLAYLIST_SERVICE_ID_KEY = "playlist_service_id"; - private static final String JSON_PLAYLIST_URL_KEY = "playlist_url"; - private static final String JSON_PLAYLIST_NAME_KEY = "playlist_name"; - private static final String JSON_PLAYLIST_ID_KEY = "playlist_id"; - private static final String JSON_PLAYLIST_TYPE_KEY = "playlist_type"; - private int playlistServiceId; - private String playlistUrl; - private String playlistName; - private long playlistId; - private LocalItemType playlistType; - - private PlaylistTab() { - this(-1, NO_NAME); - } - - public PlaylistTab(final long playlistId, final String playlistName) { - this.playlistName = playlistName; - this.playlistId = playlistId; - this.playlistType = LocalItemType.PLAYLIST_LOCAL_ITEM; - this.playlistServiceId = -1; - this.playlistUrl = NO_URL; - } - - public PlaylistTab(final int playlistServiceId, final String playlistUrl, - final String playlistName) { - this.playlistServiceId = playlistServiceId; - this.playlistUrl = playlistUrl; - this.playlistName = playlistName; - this.playlistType = LocalItemType.PLAYLIST_REMOTE_ITEM; - this.playlistId = -1; - } - - public PlaylistTab(final JsonObject jsonObject) { - super(jsonObject); - } - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return playlistName; - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return R.drawable.ic_bookmark; - } - - @Override - public Fragment getFragment(final Context context) { - if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { - return LocalPlaylistFragment.getInstance(playlistId, playlistName); - - } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM - return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); - } - } - - @Override - protected void writeDataToJson(final JsonStringWriter writerSink) { - writerSink.value(JSON_PLAYLIST_SERVICE_ID_KEY, playlistServiceId) - .value(JSON_PLAYLIST_URL_KEY, playlistUrl) - .value(JSON_PLAYLIST_NAME_KEY, playlistName) - .value(JSON_PLAYLIST_ID_KEY, playlistId) - .value(JSON_PLAYLIST_TYPE_KEY, playlistType.toString()); - } - - @Override - protected void readDataFromJson(final JsonObject jsonObject) { - playlistServiceId = jsonObject.getInt(JSON_PLAYLIST_SERVICE_ID_KEY, -1); - playlistUrl = jsonObject.getString(JSON_PLAYLIST_URL_KEY, NO_URL); - playlistName = jsonObject.getString(JSON_PLAYLIST_NAME_KEY, NO_NAME); - playlistId = jsonObject.getInt(JSON_PLAYLIST_ID_KEY, -1); - playlistType = LocalItemType.valueOf( - jsonObject.getString(JSON_PLAYLIST_TYPE_KEY, - LocalItemType.PLAYLIST_LOCAL_ITEM.toString()) - ); - } - - @Override - public boolean equals(final Object obj) { - if (!(obj instanceof PlaylistTab)) { - return false; - } - - final PlaylistTab other = (PlaylistTab) obj; - - return super.equals(obj) - && playlistServiceId == other.playlistServiceId // Remote - && playlistId == other.playlistId // Local - && playlistUrl.equals(other.playlistUrl) - && playlistName.equals(other.playlistName) - && playlistType == other.playlistType; - } - - @Override - public int hashCode() { - return Objects.hash( - getTabId(), - playlistServiceId, - playlistId, - playlistUrl, - playlistName, - playlistType - ); - } - - public int getPlaylistServiceId() { - return playlistServiceId; - } - - public String getPlaylistUrl() { - return playlistUrl; - } - - public String getPlaylistName() { - return playlistName; - } - - public long getPlaylistId() { - return playlistId; - } - - public LocalItemType getPlaylistType() { - return playlistType; - } - } - public static class FeedGroupTab extends Tab { - public static final int ID = 9; - private static final String JSON_FEED_GROUP_ID_KEY = "feed_group_id"; - private static final String JSON_FEED_GROUP_NAME_KEY = "feed_group_name"; - private static final String JSON_FEED_GROUP_ICON_KEY = "feed_group_icon"; - private Long feedGroupId; - private String feedGroupName; - private int iconId; - - private FeedGroupTab() { - this((long) -1, NO_NAME, R.drawable.ic_asterisk); - } - - public FeedGroupTab(final Long feedGroupId, final String feedGroupName, - final int iconId) { - this.feedGroupId = feedGroupId; - this.feedGroupName = feedGroupName; - this.iconId = iconId; - - } - - public FeedGroupTab(final JsonObject jsonObject) { - super(jsonObject); - } - - @Override - public int getTabId() { - return ID; - } - - @Override - public String getTabName(final Context context) { - return context.getString(R.string.fragment_feed_title); - } - - @DrawableRes - @Override - public int getTabIconRes(final Context context) { - return this.iconId; - } - - @Override - public FeedFragment getFragment(final Context context) { - return FeedFragment.newInstance(feedGroupId, feedGroupName); - } - - @Override - protected void writeDataToJson(final JsonStringWriter writerSink) { - writerSink.value(JSON_FEED_GROUP_ID_KEY, feedGroupId) - .value(JSON_FEED_GROUP_NAME_KEY, feedGroupName) - .value(JSON_FEED_GROUP_ICON_KEY, iconId); - } - - @Override - protected void readDataFromJson(final JsonObject jsonObject) { - feedGroupId = jsonObject.getLong(JSON_FEED_GROUP_ID_KEY, -1); - feedGroupName = jsonObject.getString(JSON_FEED_GROUP_NAME_KEY, NO_NAME); - iconId = jsonObject.getInt(JSON_FEED_GROUP_ICON_KEY, R.drawable.ic_asterisk); - } - - @Override - public boolean equals(final Object obj) { - if (!(obj instanceof FeedGroupTab)) { - return false; - } - final FeedGroupTab other = (FeedGroupTab) obj; - return super.equals(obj) - && feedGroupId.equals(other.feedGroupId) - && feedGroupName.equals(other.feedGroupName) - && iconId == other.iconId; - } - - @Override - public int hashCode() { - return Objects.hash(getTabId(), feedGroupId, feedGroupName, iconId); - } - - public Long getFeedGroupId() { - return feedGroupId; - } - - public String getFeedGroupName() { - return feedGroupName; - } - - public int getIconId() { - return iconId; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java deleted file mode 100644 index c42348be0..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.schabi.newpipe.settings.tabs; - -import androidx.annotation.Nullable; - -import com.grack.nanojson.JsonArray; -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonParserException; -import com.grack.nanojson.JsonStringWriter; -import com.grack.nanojson.JsonWriter; - -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * Class to get a JSON representation of a list of tabs, and the other way around. - */ -public final class TabsJsonHelper { - private static final String JSON_TABS_ARRAY_KEY = "tabs"; - - private static final List FALLBACK_INITIAL_TABS_LIST = List.of( - Tab.Type.DEFAULT_KIOSK.getTab(), - Tab.Type.FEED.getTab(), - Tab.Type.SUBSCRIPTIONS.getTab(), - Tab.Type.BOOKMARKS.getTab()); - - private TabsJsonHelper() { } - - /** - * Try to reads the passed JSON and returns the list of tabs if no error were encountered. - *

- * If the JSON is null or empty, or the list of tabs that it represents is empty, the - * {@link #getDefaultTabs fallback list} will be returned. - *

- * Tabs with invalid ids (i.e. not in the {@link Tab.Type} enum) will be ignored. - * - * @param tabsJson a JSON string got from {@link #getJsonToSave(List)}. - * @return a list of {@link Tab tabs}. - * @throws InvalidJsonException if the JSON string is not valid - */ - public static List getTabsFromJson(@Nullable final String tabsJson) - throws InvalidJsonException { - if (tabsJson == null || tabsJson.isEmpty()) { - return getDefaultTabs(); - } - - try { - final JsonObject outerJsonObject = JsonParser.object().from(tabsJson); - - if (!outerJsonObject.has(JSON_TABS_ARRAY_KEY)) { - throw new InvalidJsonException("JSON doesn't contain \"" + JSON_TABS_ARRAY_KEY - + "\" array"); - } - - final JsonArray tabsArray = outerJsonObject.getArray(JSON_TABS_ARRAY_KEY, null); - - final var returnTabs = tabsArray.streamAsJsonObjects() - .map(Tab::from) - .filter(Objects::nonNull) - .collect(Collectors.toUnmodifiableList()); - - return returnTabs.isEmpty() ? getDefaultTabs() : returnTabs; - } catch (final JsonParserException e) { - throw new InvalidJsonException(e); - } - } - - /** - * Get a JSON representation from a list of tabs. - * - * @param tabList a list of {@link Tab tabs}. - * @return a JSON string representing the list of tabs - */ - public static String getJsonToSave(@Nullable final List tabList) { - final JsonStringWriter jsonWriter = JsonWriter.string(); - jsonWriter.object(); - - jsonWriter.array(JSON_TABS_ARRAY_KEY); - if (tabList != null) { - for (final Tab tab : tabList) { - tab.writeJsonOn(jsonWriter); - } - } - jsonWriter.end(); - - jsonWriter.end(); - return jsonWriter.done(); - } - - public static List getDefaultTabs() { - return FALLBACK_INITIAL_TABS_LIST; - } - - public static final class InvalidJsonException extends Exception { - private InvalidJsonException() { - super(); - } - - private InvalidJsonException(final String message) { - super(message); - } - - private InvalidJsonException(final Throwable cause) { - super(cause); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java deleted file mode 100644 index 7dcbee56f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.schabi.newpipe.settings.tabs; - -import android.content.Context; -import android.content.SharedPreferences; -import android.widget.Toast; - -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; - -import java.util.List; - -public final class TabsManager { - private final SharedPreferences sharedPreferences; - private final String savedTabsKey; - private final Context context; - private SavedTabsChangeListener savedTabsChangeListener; - private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; - - private TabsManager(final Context context) { - this.context = context; - this.sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - this.savedTabsKey = context.getString(R.string.saved_tabs_key); - } - - public static TabsManager getManager(final Context context) { - return new TabsManager(context); - } - - public List getTabs() { - final String savedJson = sharedPreferences.getString(savedTabsKey, null); - try { - return TabsJsonHelper.getTabsFromJson(savedJson); - } catch (final TabsJsonHelper.InvalidJsonException e) { - Toast.makeText(context, R.string.saved_tabs_invalid_json, Toast.LENGTH_SHORT).show(); - return getDefaultTabs(); - } - } - - public void saveTabs(final List tabList) { - final String jsonToSave = TabsJsonHelper.getJsonToSave(tabList); - sharedPreferences.edit().putString(savedTabsKey, jsonToSave).apply(); - } - - public void resetTabs() { - sharedPreferences.edit().remove(savedTabsKey).apply(); - } - - public List getDefaultTabs() { - return TabsJsonHelper.getDefaultTabs(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Listener - //////////////////////////////////////////////////////////////////////////*/ - - public void setSavedTabsListener(final SavedTabsChangeListener listener) { - if (preferenceChangeListener != null) { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - } - savedTabsChangeListener = listener; - preferenceChangeListener = getPreferenceChangeListener(); - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener); - } - - public void unsetSavedTabsListener() { - if (preferenceChangeListener != null) { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - } - preferenceChangeListener = null; - savedTabsChangeListener = null; - } - - private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() { - return (sp, key) -> { - if (savedTabsKey.equals(key) && savedTabsChangeListener != null) { - savedTabsChangeListener.onTabsChanged(); - } - }; - } - - public interface SavedTabsChangeListener { - void onTabsChanged(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java deleted file mode 100644 index 68225fbab..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java +++ /dev/null @@ -1,267 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.EOFException; -import java.io.IOException; -import java.io.InputStream; - -/** - * @author kapodamy - */ -public class DataReader { - public static final int SHORT_SIZE = 2; - public static final int LONG_SIZE = 8; - public static final int INTEGER_SIZE = 4; - public static final int FLOAT_SIZE = 4; - - private static final int BUFFER_SIZE = 128 * 1024; // 128 KiB - - private long position = 0; - private final SharpStream stream; - - private InputStream view; - private int viewSize; - - public DataReader(final SharpStream stream) { - this.stream = stream; - this.readOffset = this.readBuffer.length; - } - - public long position() { - return position; - } - - public int read() throws IOException { - if (fillBuffer()) { - return -1; - } - - position++; - readCount--; - - return readBuffer[readOffset++] & 0xFF; - } - - public long skipBytes(final long byteAmount) throws IOException { - long amount = byteAmount; - if (readCount < 0) { - return 0; - } else if (readCount == 0) { - amount = stream.skip(amount); - } else { - if (readCount > amount) { - readCount -= (int) amount; - readOffset += (int) amount; - } else { - amount = readCount + stream.skip(amount - readCount); - readCount = 0; - readOffset = readBuffer.length; - } - } - - position += amount; - return amount; - } - - public int readInt() throws IOException { - primitiveRead(INTEGER_SIZE); - return primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; - } - - public long readUnsignedInt() throws IOException { - final long value = readInt(); - return value & 0xffffffffL; - } - - - public short readShort() throws IOException { - primitiveRead(SHORT_SIZE); - return (short) (primitive[0] << 8 | primitive[1]); - } - - public long readLong() throws IOException { - primitiveRead(LONG_SIZE); - final long high = - primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; - final long low = primitive[4] << 24 | primitive[5] << 16 | primitive[6] << 8 | primitive[7]; - return high << 32 | low; - } - - public int read(final byte[] buffer) throws IOException { - return read(buffer, 0, buffer.length); - } - - public int read(final byte[] buffer, final int off, final int c) throws IOException { - int offset = off; - int count = c; - - if (readCount < 0) { - return -1; - } - int total = 0; - - if (count >= readBuffer.length) { - if (readCount > 0) { - System.arraycopy(readBuffer, readOffset, buffer, offset, readCount); - readOffset += readCount; - - offset += readCount; - count -= readCount; - - total = readCount; - readCount = 0; - } - total += Math.max(stream.read(buffer, offset, count), 0); - } else { - while (count > 0 && !fillBuffer()) { - final int read = Math.min(readCount, count); - System.arraycopy(readBuffer, readOffset, buffer, offset, read); - - readOffset += read; - readCount -= read; - - offset += read; - count -= read; - - total += read; - } - } - - position += total; - return total; - } - - public boolean available() { - return readCount > 0 || stream.available() > 0; - } - - public void rewind() throws IOException { - stream.rewind(); - - if ((position - viewSize) > 0) { - viewSize = 0; // drop view - } else { - viewSize += position; - } - - position = 0; - readOffset = readBuffer.length; - readCount = 0; - } - - public boolean canRewind() { - return stream.canRewind(); - } - - /** - * Wraps this instance of {@code DataReader} into {@code InputStream} - * object. Note: Any read in the {@code DataReader} will not modify - * (decrease) the view size - * - * @param size the size of the view - * @return the view - */ - public InputStream getView(final int size) { - if (view == null) { - view = new InputStream() { - @Override - public int read() throws IOException { - if (viewSize < 1) { - return -1; - } - final int res = DataReader.this.read(); - if (res > 0) { - viewSize--; - } - return res; - } - - @Override - public int read(final byte[] buffer) throws IOException { - return read(buffer, 0, buffer.length); - } - - @Override - public int read(final byte[] buffer, final int offset, final int count) - throws IOException { - if (viewSize < 1) { - return -1; - } - - final int res = DataReader.this.read(buffer, offset, Math.min(viewSize, count)); - viewSize -= res; - - return res; - } - - @Override - public long skip(final long amount) throws IOException { - if (viewSize < 1) { - return 0; - } - final int res = (int) DataReader.this.skipBytes(Math.min(amount, viewSize)); - viewSize -= res; - - return res; - } - - @Override - public int available() { - return viewSize; - } - - @Override - public void close() { - viewSize = 0; - } - - @Override - public boolean markSupported() { - return false; - } - - }; - } - viewSize = size; - - return view; - } - - private final short[] primitive = new short[LONG_SIZE]; - - private void primitiveRead(final int amount) throws IOException { - final byte[] buffer = new byte[amount]; - final int read = read(buffer, 0, amount); - - if (read != amount) { - throw new EOFException("Truncated stream, missing " - + (amount - read) + " bytes"); - } - - for (int i = 0; i < amount; i++) { - // the "byte" data type in java is signed and is very annoying - primitive[i] = (short) (buffer[i] & 0xFF); - } - } - - private final byte[] readBuffer = new byte[BUFFER_SIZE]; - private int readOffset; - private int readCount; - - private boolean fillBuffer() throws IOException { - if (readCount < 0) { - return true; - } - if (readOffset >= readBuffer.length) { - readCount = stream.read(readBuffer); - if (readCount < 1) { - readCount = -1; - return true; - } - readOffset = 0; - } - - return readCount < 1; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java deleted file mode 100644 index de327fba1..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java +++ /dev/null @@ -1,943 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.EOFException; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.NoSuchElementException; - -/** - * @author kapodamy - */ -public class Mp4DashReader { - private static final int ATOM_MOOF = 0x6D6F6F66; - private static final int ATOM_MFHD = 0x6D666864; - private static final int ATOM_TRAF = 0x74726166; - private static final int ATOM_TFHD = 0x74666864; - private static final int ATOM_TFDT = 0x74666474; - private static final int ATOM_TRUN = 0x7472756E; - private static final int ATOM_MDIA = 0x6D646961; - private static final int ATOM_FTYP = 0x66747970; - private static final int ATOM_SIDX = 0x73696478; - private static final int ATOM_MOOV = 0x6D6F6F76; - private static final int ATOM_MDAT = 0x6D646174; - private static final int ATOM_MVHD = 0x6D766864; - private static final int ATOM_TRAK = 0x7472616B; - private static final int ATOM_MVEX = 0x6D766578; - private static final int ATOM_TREX = 0x74726578; - private static final int ATOM_TKHD = 0x746B6864; - private static final int ATOM_MFRA = 0x6D667261; - private static final int ATOM_MDHD = 0x6D646864; - private static final int ATOM_EDTS = 0x65647473; - private static final int ATOM_ELST = 0x656C7374; - private static final int ATOM_HDLR = 0x68646C72; - private static final int ATOM_MINF = 0x6D696E66; - private static final int ATOM_DINF = 0x64696E66; - private static final int ATOM_STBL = 0x7374626C; - private static final int ATOM_STSD = 0x73747364; - private static final int ATOM_VMHD = 0x766D6864; - private static final int ATOM_SMHD = 0x736D6864; - - private static final int BRAND_DASH = 0x64617368; - private static final int BRAND_ISO5 = 0x69736F35; - - private static final int HANDLER_VIDE = 0x76696465; - private static final int HANDLER_SOUN = 0x736F756E; - private static final int HANDLER_SUBT = 0x73756274; - - private final DataReader stream; - - private Mp4Track[] tracks = null; - private int[] brands = null; - - private Box box; - private Moof moof; - - private boolean chunkZero = false; - - private int selectedTrack = -1; - private Box backupBox = null; - - public enum TrackKind { - Audio, Video, Subtitles, Other - } - - public Mp4DashReader(final SharpStream source) { - this.stream = new DataReader(source); - } - - public void parse() throws IOException, NoSuchElementException { - if (selectedTrack > -1) { - return; - } - - box = readBox(ATOM_FTYP); - brands = parseFtyp(box); - switch (brands[0]) { - case BRAND_DASH: - case BRAND_ISO5:// ¿why not? - break; - default: - throw new NoSuchElementException( - "Not a MPEG-4 DASH container, major brand is not 'dash' or 'iso5' is " - + boxName(brands[0]) - ); - } - - Moov moov = null; - int i; - - while (box.type != ATOM_MOOF) { - ensure(box); - box = readBox(); - - switch (box.type) { - case ATOM_MOOV: - moov = parseMoov(box); - break; - case ATOM_SIDX: - case ATOM_MFRA: - break; - } - } - - if (moov == null) { - throw new IOException("The provided Mp4 doesn't have the 'moov' box"); - } - - tracks = new Mp4Track[moov.trak.length]; - - for (i = 0; i < tracks.length; i++) { - tracks[i] = new Mp4Track(); - tracks[i].trak = moov.trak[i]; - - if (moov.mvexTrex != null) { - for (final Trex mvexTrex : moov.mvexTrex) { - if (tracks[i].trak.tkhd.trackId == mvexTrex.trackId) { - tracks[i].trex = mvexTrex; - } - } - } - - switch (moov.trak[i].mdia.hdlr.subType) { - case HANDLER_VIDE: - tracks[i].kind = TrackKind.Video; - break; - case HANDLER_SOUN: - tracks[i].kind = TrackKind.Audio; - break; - case HANDLER_SUBT: - tracks[i].kind = TrackKind.Subtitles; - break; - default: - tracks[i].kind = TrackKind.Other; - break; - } - } - - backupBox = box; - } - - Mp4Track selectTrack(final int index) { - selectedTrack = index; - return tracks[index]; - } - - public int[] getBrands() { - if (brands == null) { - throw new IllegalStateException("Not parsed"); - } - return brands; - } - - public void rewind() throws IOException { - if (!stream.canRewind()) { - throw new IOException("The provided stream doesn't allow seek"); - } - if (box == null) { - return; - } - - box = backupBox; - chunkZero = false; - - stream.rewind(); - stream.skipBytes(backupBox.offset + (DataReader.INTEGER_SIZE * 2)); - } - - public Mp4Track[] getAvailableTracks() { - return tracks; - } - - public Mp4DashChunk getNextChunk(final boolean infoOnly) throws IOException { - final Mp4Track track = tracks[selectedTrack]; - - while (stream.available()) { - - if (chunkZero) { - ensure(box); - if (!stream.available()) { - break; - } - box = readBox(); - } else { - chunkZero = true; - } - - switch (box.type) { - case ATOM_MOOF: - if (moof != null) { - throw new IOException("moof found without mdat"); - } - - moof = parseMoof(box, track.trak.tkhd.trackId); - - if (moof.traf != null) { - - if (hasFlag(moof.traf.trun.bFlags, 0x0001)) { - moof.traf.trun.dataOffset -= box.size + 8; - if (moof.traf.trun.dataOffset < 0) { - throw new IOException("trun box has wrong data offset, " - + "points outside of concurrent mdat box"); - } - } - - if (moof.traf.trun.chunkSize < 1) { - if (hasFlag(moof.traf.tfhd.bFlags, 0x10)) { - moof.traf.trun.chunkSize = moof.traf.tfhd.defaultSampleSize - * moof.traf.trun.entryCount; - } else { - moof.traf.trun.chunkSize = (int) (box.size - 8); - } - } - if (!hasFlag(moof.traf.trun.bFlags, 0x900) - && moof.traf.trun.chunkDuration == 0) { - if (hasFlag(moof.traf.tfhd.bFlags, 0x20)) { - moof.traf.trun.chunkDuration = moof.traf.tfhd.defaultSampleDuration - * moof.traf.trun.entryCount; - } - } - } - break; - case ATOM_MDAT: - if (moof == null) { - throw new IOException("mdat found without moof"); - } - - if (moof.traf == null) { - moof = null; - continue; // find another chunk - } - - final Mp4DashChunk chunk = new Mp4DashChunk(); - chunk.moof = moof; - if (!infoOnly) { - chunk.data = stream.getView(moof.traf.trun.chunkSize); - } - - moof = null; - - stream.skipBytes(chunk.moof.traf.trun.dataOffset); - return chunk; - default: - } - } - - return null; - } - - public static boolean hasFlag(final int flags, final int mask) { - return (flags & mask) == mask; - } - - private String boxName(final Box ref) { - return boxName(ref.type); - } - - private String boxName(final int type) { - return new String(ByteBuffer.allocate(4).putInt(type).array(), StandardCharsets.UTF_8); - } - - private Box readBox() throws IOException { - final Box b = new Box(); - b.offset = stream.position(); - b.size = stream.readUnsignedInt(); - b.type = stream.readInt(); - - if (b.size == 1) { - b.size = stream.readLong(); - } - - return b; - } - - private Box readBox(final int expected) throws IOException { - final Box b = readBox(); - if (b.type != expected) { - throw new NoSuchElementException("expected " + boxName(expected) - + " found " + boxName(b)); - } - return b; - } - - private byte[] readFullBox(final Box ref) throws IOException { - // full box reading is limited to 2 GiB, and should be enough - final int size = (int) ref.size; - - final ByteBuffer buffer = ByteBuffer.allocate(size); - buffer.putInt(size); - buffer.putInt(ref.type); - - final int read = size - 8; - - if (stream.read(buffer.array(), 8, read) != read) { - throw new EOFException(String.format("EOF reached in box: type=%s offset=%s size=%s", - boxName(ref.type), ref.offset, ref.size)); - } - - return buffer.array(); - } - - private void ensure(final Box ref) throws IOException { - final long skip = ref.offset + ref.size - stream.position(); - - if (skip == 0) { - return; - } else if (skip < 0) { - throw new EOFException(String.format( - "parser go beyond limits of the box. type=%s offset=%s size=%s position=%s", - boxName(ref), ref.offset, ref.size, stream.position() - )); - } - - stream.skipBytes((int) skip); - } - - private Box untilBox(final Box ref, final int... expected) throws IOException { - Box b; - while (stream.position() < (ref.offset + ref.size)) { - b = readBox(); - for (final int type : expected) { - if (b.type == type) { - return b; - } - } - ensure(b); - } - - return null; - } - - private Box untilAnyBox(final Box ref) throws IOException { - if (stream.position() >= (ref.offset + ref.size)) { - return null; - } - - return readBox(); - } - - private Moof parseMoof(final Box ref, final int trackId) throws IOException { - final Moof obj = new Moof(); - - Box b = readBox(ATOM_MFHD); - obj.mfhdSequenceNumber = parseMfhd(); - ensure(b); - - while ((b = untilBox(ref, ATOM_TRAF)) != null) { - obj.traf = parseTraf(b, trackId); - ensure(b); - - if (obj.traf != null) { - return obj; - } - } - - return obj; - } - - private int parseMfhd() throws IOException { - // version - // flags - stream.skipBytes(4); - - return stream.readInt(); - } - - private Traf parseTraf(final Box ref, final int trackId) throws IOException { - final Traf traf = new Traf(); - - Box b = readBox(ATOM_TFHD); - traf.tfhd = parseTfhd(trackId); - ensure(b); - - if (traf.tfhd == null) { - return null; - } - - b = untilBox(ref, ATOM_TRUN, ATOM_TFDT); - - if (b.type == ATOM_TFDT) { - traf.tfdt = parseTfdt(); - ensure(b); - b = readBox(ATOM_TRUN); - } - - traf.trun = parseTrun(); - ensure(b); - - return traf; - } - - private Tfhd parseTfhd(final int trackId) throws IOException { - final Tfhd obj = new Tfhd(); - - obj.bFlags = stream.readInt(); - obj.trackId = stream.readInt(); - - if (trackId != -1 && obj.trackId != trackId) { - return null; - } - - if (hasFlag(obj.bFlags, 0x01)) { - stream.skipBytes(8); - } - if (hasFlag(obj.bFlags, 0x02)) { - stream.skipBytes(4); - } - if (hasFlag(obj.bFlags, 0x08)) { - obj.defaultSampleDuration = stream.readInt(); - } - if (hasFlag(obj.bFlags, 0x10)) { - obj.defaultSampleSize = stream.readInt(); - } - if (hasFlag(obj.bFlags, 0x20)) { - obj.defaultSampleFlags = stream.readInt(); - } - - return obj; - } - - private long parseTfdt() throws IOException { - final int version = stream.read(); - stream.skipBytes(3); // flags - return version == 0 ? stream.readUnsignedInt() : stream.readLong(); - } - - private Trun parseTrun() throws IOException { - final Trun obj = new Trun(); - obj.bFlags = stream.readInt(); - obj.entryCount = stream.readInt(); // unsigned int - - obj.entriesRowSize = 0; - if (hasFlag(obj.bFlags, 0x0100)) { - obj.entriesRowSize += 4; - } - if (hasFlag(obj.bFlags, 0x0200)) { - obj.entriesRowSize += 4; - } - if (hasFlag(obj.bFlags, 0x0400)) { - obj.entriesRowSize += 4; - } - if (hasFlag(obj.bFlags, 0x0800)) { - obj.entriesRowSize += 4; - } - obj.bEntries = new byte[obj.entriesRowSize * obj.entryCount]; - - if (hasFlag(obj.bFlags, 0x0001)) { - obj.dataOffset = stream.readInt(); - } - if (hasFlag(obj.bFlags, 0x0004)) { - obj.bFirstSampleFlags = stream.readInt(); - } - - stream.read(obj.bEntries); - - for (int i = 0; i < obj.entryCount; i++) { - final TrunEntry entry = obj.getEntry(i); - if (hasFlag(obj.bFlags, 0x0100)) { - obj.chunkDuration += entry.sampleDuration; - } - if (hasFlag(obj.bFlags, 0x0200)) { - obj.chunkSize += entry.sampleSize; - } - if (hasFlag(obj.bFlags, 0x0800)) { - if (!hasFlag(obj.bFlags, 0x0100)) { - obj.chunkDuration += entry.sampleCompositionTimeOffset; - } - } - } - - return obj; - } - - private int[] parseFtyp(final Box ref) throws IOException { - int i = 0; - final int[] list = new int[(int) ((ref.offset + ref.size - stream.position() - 4) / 4)]; - - list[i++] = stream.readInt(); // major brand - - stream.skipBytes(4); // minor version - - for (; i < list.length; i++) { - list[i] = stream.readInt(); // compatible brands - } - - return list; - } - - private Mvhd parseMvhd() throws IOException { - final int version = stream.read(); - stream.skipBytes(3); // flags - - // creation entries_time - // modification entries_time - stream.skipBytes(2 * (version == 0 ? 4 : 8)); - - final Mvhd obj = new Mvhd(); - obj.timeScale = stream.readUnsignedInt(); - - // chunkDuration - stream.skipBytes(version == 0 ? 4 : 8); - - // rate - // volume - // reserved - // matrix array - // predefined - stream.skipBytes(76); - - obj.nextTrackId = stream.readUnsignedInt(); - - return obj; - } - - private Tkhd parseTkhd() throws IOException { - final int version = stream.read(); - - final Tkhd obj = new Tkhd(); - - // flags - // creation entries_time - // modification entries_time - stream.skipBytes(3 + (2 * (version == 0 ? 4 : 8))); - - obj.trackId = stream.readInt(); - - stream.skipBytes(4); // reserved - - obj.duration = version == 0 ? stream.readUnsignedInt() : stream.readLong(); - - stream.skipBytes(2 * 4); // reserved - - obj.bLayer = stream.readShort(); - obj.bAlternateGroup = stream.readShort(); - obj.bVolume = stream.readShort(); - - stream.skipBytes(2); // reserved - - obj.matrix = new byte[9 * 4]; - stream.read(obj.matrix); - - obj.bWidth = stream.readInt(); - obj.bHeight = stream.readInt(); - - return obj; - } - - private Trak parseTrak(final Box ref) throws IOException { - final Trak trak = new Trak(); - - Box b = readBox(ATOM_TKHD); - trak.tkhd = parseTkhd(); - ensure(b); - - while ((b = untilBox(ref, ATOM_MDIA, ATOM_EDTS)) != null) { - switch (b.type) { - case ATOM_MDIA: - trak.mdia = parseMdia(b); - break; - case ATOM_EDTS: - trak.edstElst = parseEdts(b); - break; - } - - ensure(b); - } - - return trak; - } - - private Mdia parseMdia(final Box ref) throws IOException { - final Mdia obj = new Mdia(); - - Box b; - while ((b = untilBox(ref, ATOM_MDHD, ATOM_HDLR, ATOM_MINF)) != null) { - switch (b.type) { - case ATOM_MDHD: - obj.mdhd = readFullBox(b); - - // read time scale - final ByteBuffer buffer = ByteBuffer.wrap(obj.mdhd); - final byte version = buffer.get(8); - buffer.position(12 + ((version == 0 ? 4 : 8) * 2)); - obj.mdhdTimeScale = buffer.getInt(); - break; - case ATOM_HDLR: - obj.hdlr = parseHdlr(b); - break; - case ATOM_MINF: - obj.minf = parseMinf(b); - break; - } - ensure(b); - } - - return obj; - } - - private Hdlr parseHdlr(final Box ref) throws IOException { - // version - // flags - stream.skipBytes(4); - - final Hdlr obj = new Hdlr(); - obj.bReserved = new byte[12]; - - obj.type = stream.readInt(); - obj.subType = stream.readInt(); - stream.read(obj.bReserved); - - // component name (is a ansi/ascii string) - stream.skipBytes((ref.offset + ref.size) - stream.position()); - - return obj; - } - - private Moov parseMoov(final Box ref) throws IOException { - Box b = readBox(ATOM_MVHD); - final Moov moov = new Moov(); - moov.mvhd = parseMvhd(); - ensure(b); - - final ArrayList tmp = new ArrayList<>((int) moov.mvhd.nextTrackId); - while ((b = untilBox(ref, ATOM_TRAK, ATOM_MVEX)) != null) { - - switch (b.type) { - case ATOM_TRAK: - tmp.add(parseTrak(b)); - break; - case ATOM_MVEX: - moov.mvexTrex = parseMvex(b, (int) moov.mvhd.nextTrackId); - break; - } - - ensure(b); - } - - moov.trak = tmp.toArray(new Trak[0]); - - return moov; - } - - private Trex[] parseMvex(final Box ref, final int possibleTrackCount) throws IOException { - final ArrayList tmp = new ArrayList<>(possibleTrackCount); - - Box b; - while ((b = untilBox(ref, ATOM_TREX)) != null) { - tmp.add(parseTrex()); - ensure(b); - } - - return tmp.toArray(new Trex[0]); - } - - private Trex parseTrex() throws IOException { - // version - // flags - stream.skipBytes(4); - - final Trex obj = new Trex(); - obj.trackId = stream.readInt(); - obj.defaultSampleDescriptionIndex = stream.readInt(); - obj.defaultSampleDuration = stream.readInt(); - obj.defaultSampleSize = stream.readInt(); - obj.defaultSampleFlags = stream.readInt(); - - return obj; - } - - private Elst parseEdts(final Box ref) throws IOException { - final Box b = untilBox(ref, ATOM_ELST); - if (b == null) { - return null; - } - - final Elst obj = new Elst(); - - final boolean v1 = stream.read() == 1; - stream.skipBytes(3); // flags - - final int entryCount = stream.readInt(); - if (entryCount < 1) { - obj.bMediaRate = 0x00010000; // default media rate (1.0) - return obj; - } - - if (v1) { - stream.skipBytes(DataReader.LONG_SIZE); // segment duration - obj.mediaTime = stream.readLong(); - // ignore all remain entries - stream.skipBytes((entryCount - 1) * (DataReader.LONG_SIZE * 2)); - } else { - stream.skipBytes(DataReader.INTEGER_SIZE); // segment duration - obj.mediaTime = stream.readInt(); - } - - obj.bMediaRate = stream.readInt(); - - return obj; - } - - private Minf parseMinf(final Box ref) throws IOException { - final Minf obj = new Minf(); - - Box b; - while ((b = untilAnyBox(ref)) != null) { - - switch (b.type) { - case ATOM_DINF: - obj.dinf = readFullBox(b); - break; - case ATOM_STBL: - obj.stblStsd = parseStbl(b); - break; - case ATOM_VMHD: - case ATOM_SMHD: - obj.mhd = readFullBox(b); - break; - - } - ensure(b); - } - - return obj; - } - - /** - * This only reads the "stsd" box inside. - * - * @param ref stbl box - * @return stsd box inside - */ - private byte[] parseStbl(final Box ref) throws IOException { - final Box b = untilBox(ref, ATOM_STSD); - - if (b == null) { - return new byte[0]; // this never should happens (missing codec startup data) - } - - return readFullBox(b); - } - - static class Box { - int type; - long offset; - long size; - } - - public static class Moof { - int mfhdSequenceNumber; - public Traf traf; - } - - public static class Traf { - public Tfhd tfhd; - long tfdt; - public Trun trun; - } - - public static class Tfhd { - int bFlags; - public int trackId; - int defaultSampleDuration; - int defaultSampleSize; - int defaultSampleFlags; - } - - static class TrunEntry { - int sampleDuration; - int sampleSize; - int sampleFlags; - int sampleCompositionTimeOffset; - - boolean hasCompositionTimeOffset; - boolean isKeyframe; - - } - - public static class Trun { - public int chunkDuration; - public int chunkSize; - - public int bFlags; - int bFirstSampleFlags; - int dataOffset; - - public int entryCount; - byte[] bEntries; - int entriesRowSize; - - public TrunEntry getEntry(final int i) { - final ByteBuffer buffer = ByteBuffer.wrap(bEntries, i * entriesRowSize, entriesRowSize); - final TrunEntry entry = new TrunEntry(); - - if (hasFlag(bFlags, 0x0100)) { - entry.sampleDuration = buffer.getInt(); - } - if (hasFlag(bFlags, 0x0200)) { - entry.sampleSize = buffer.getInt(); - } - if (hasFlag(bFlags, 0x0400)) { - entry.sampleFlags = buffer.getInt(); - } - if (hasFlag(bFlags, 0x0800)) { - entry.sampleCompositionTimeOffset = buffer.getInt(); - } - - entry.hasCompositionTimeOffset = hasFlag(bFlags, 0x0800); - entry.isKeyframe = !hasFlag(entry.sampleFlags, 0x10000); - - return entry; - } - - public TrunEntry getAbsoluteEntry(final int i, final Tfhd header) { - final TrunEntry entry = getEntry(i); - - if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x20)) { - entry.sampleFlags = header.defaultSampleFlags; - } - - if (!hasFlag(bFlags, 0x0200) && hasFlag(header.bFlags, 0x10)) { - entry.sampleSize = header.defaultSampleSize; - } - - if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x08)) { - entry.sampleDuration = header.defaultSampleDuration; - } - - if (i == 0 && hasFlag(bFlags, 0x0004)) { - entry.sampleFlags = bFirstSampleFlags; - } - - return entry; - } - } - - public static class Tkhd { - int trackId; - long duration; - short bVolume; - int bWidth; - int bHeight; - byte[] matrix; - short bLayer; - short bAlternateGroup; - } - - public static class Trak { - public Tkhd tkhd; - public Elst edstElst; - public Mdia mdia; - - } - - static class Mvhd { - long timeScale; - long nextTrackId; - } - - static class Moov { - Mvhd mvhd; - Trak[] trak; - Trex[] mvexTrex; - } - - public static class Trex { - private int trackId; - int defaultSampleDescriptionIndex; - int defaultSampleDuration; - int defaultSampleSize; - int defaultSampleFlags; - } - - public static class Elst { - public long mediaTime; - public int bMediaRate; - } - - public static class Mdia { - public int mdhdTimeScale; - public byte[] mdhd; - public Hdlr hdlr; - public Minf minf; - } - - public static class Hdlr { - public int type; - public int subType; - public byte[] bReserved; - } - - public static class Minf { - public byte[] dinf; - public byte[] stblStsd; - public byte[] mhd; - } - - public static class Mp4Track { - public TrackKind kind; - public Trak trak; - public Trex trex; - } - - public static class Mp4DashChunk { - public InputStream data; - public Moof moof; - private int i = 0; - - public TrunEntry getNextSampleInfo() { - if (i >= moof.traf.trun.entryCount) { - return null; - } - return moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); - } - - public Mp4DashSample getNextSample() throws IOException { - if (data == null) { - throw new IllegalStateException("This chunk has info only"); - } - if (i >= moof.traf.trun.entryCount) { - return null; - } - - final Mp4DashSample sample = new Mp4DashSample(); - sample.info = moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); - sample.data = new byte[sample.info.sampleSize]; - - if (data.read(sample.data) != sample.info.sampleSize) { - throw new EOFException("EOF reached while reading a sample"); - } - - return sample; - } - } - - public static class Mp4DashSample { - public TrunEntry info; - public byte[] data; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java deleted file mode 100644 index 807f190b4..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ /dev/null @@ -1,912 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.schabi.newpipe.streams.Mp4DashReader.Hdlr; -import org.schabi.newpipe.streams.Mp4DashReader.Mdia; -import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk; -import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashSample; -import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; -import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; -import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; - -/** - * @author kapodamy - */ -public class Mp4FromDashWriter { - private static final int EPOCH_OFFSET = 2082844800; - private static final short DEFAULT_TIMESCALE = 1000; - private static final byte SAMPLES_PER_CHUNK_INIT = 2; - // ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 - private static final byte SAMPLES_PER_CHUNK = 6; - // near 3.999 GiB - private static final long THRESHOLD_FOR_CO64 = 0xFFFEFFFFL; - // 2.2 MiB enough for: 1080p 60fps 00h35m00s - private static final int THRESHOLD_MOOV_LENGTH = (256 * 1024) + (2048 * 1024); - - private final long time; - - private ByteBuffer auxBuffer; - private SharpStream outStream; - - private long lastWriteOffset = -1; - private long writeOffset; - - private boolean moovSimulation = true; - - private boolean done = false; - private boolean parsed = false; - - private Mp4Track[] tracks; - private SharpStream[] sourceTracks; - - private Mp4DashReader[] readers; - private Mp4DashChunk[] readersChunks; - - private int overrideMainBrand = 0x00; - - private final ArrayList compatibleBrands = new ArrayList<>(5); - - public Mp4FromDashWriter(final SharpStream... sources) throws IOException { - for (final SharpStream src : sources) { - if (!src.canRewind() && !src.canRead()) { - throw new IOException("All sources must be readable and allow rewind"); - } - } - - sourceTracks = sources; - readers = new Mp4DashReader[sourceTracks.length]; - readersChunks = new Mp4DashChunk[readers.length]; - time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; - - compatibleBrands.add(0x6D703431); // mp41 - compatibleBrands.add(0x69736F6D); // isom - compatibleBrands.add(0x69736F32); // iso2 - } - - public Mp4Track[] getTracksFromSource(final int sourceIndex) throws IllegalStateException { - if (!parsed) { - throw new IllegalStateException("All sources must be parsed first"); - } - - return readers[sourceIndex].getAvailableTracks(); - } - - public void parseSources() throws IOException, IllegalStateException { - if (done) { - throw new IllegalStateException("already done"); - } - if (parsed) { - throw new IllegalStateException("already parsed"); - } - - try { - for (int i = 0; i < readers.length; i++) { - readers[i] = new Mp4DashReader(sourceTracks[i]); - readers[i].parse(); - } - - } finally { - parsed = true; - } - } - - public void selectTracks(final int... trackIndex) throws IOException { - if (done) { - throw new IOException("already done"); - } - if (tracks != null) { - throw new IOException("tracks already selected"); - } - - try { - tracks = new Mp4Track[readers.length]; - for (int i = 0; i < readers.length; i++) { - tracks[i] = readers[i].selectTrack(trackIndex[i]); - } - } finally { - parsed = true; - } - } - - public void setMainBrand(final int brand) { - overrideMainBrand = brand; - } - - public boolean isDone() { - return done; - } - - public boolean isParsed() { - return parsed; - } - - public void close() throws IOException { - done = true; - parsed = true; - - for (final SharpStream src : sourceTracks) { - src.close(); - } - - tracks = null; - sourceTracks = null; - - readers = null; - readersChunks = null; - - auxBuffer = null; - outStream = null; - } - - @SuppressWarnings("MethodLength") - public void build(final SharpStream output) throws IOException { - if (done) { - throw new RuntimeException("already done"); - } - if (!output.canWrite()) { - throw new IOException("the provided output is not writable"); - } - - // - // WARNING: the muxer requires at least 8 samples of every track - // not allowed for very short tracks (less than 0.5 seconds) - // - outStream = output; - long read = 8; // mdat box header size - long totalSampleSize = 0; - final int[] sampleExtra = new int[readers.length]; - final int[] defaultMediaTime = new int[readers.length]; - final int[] defaultSampleDuration = new int[readers.length]; - final int[] sampleCount = new int[readers.length]; - - final TablesInfo[] tablesInfo = new TablesInfo[tracks.length]; - for (int i = 0; i < tablesInfo.length; i++) { - tablesInfo[i] = new TablesInfo(); - } - - final int singleSampleBuffer; - if (tracks.length == 1 && tracks[0].kind == TrackKind.Audio) { - // near 1 second of audio data per chunk, avoid split the audio stream in large chunks - singleSampleBuffer = tracks[0].trak.mdia.mdhdTimeScale / 1000; - } else { - singleSampleBuffer = -1; - } - - - for (int i = 0; i < readers.length; i++) { - int samplesSize = 0; - int sampleSizeChanges = 0; - int compositionOffsetLast = -1; - - Mp4DashChunk chunk; - while ((chunk = readers[i].getNextChunk(true)) != null) { - - if (defaultMediaTime[i] < 1 && chunk.moof.traf.tfhd.defaultSampleDuration > 0) { - defaultMediaTime[i] = chunk.moof.traf.tfhd.defaultSampleDuration; - } - - read += chunk.moof.traf.trun.chunkSize; - sampleExtra[i] += chunk.moof.traf.trun.chunkDuration; // calculate track duration - - TrunEntry info; - while ((info = chunk.getNextSampleInfo()) != null) { - if (info.isKeyframe) { - tablesInfo[i].stss++; - } - - if (info.sampleDuration > defaultSampleDuration[i]) { - defaultSampleDuration[i] = info.sampleDuration; - } - - tablesInfo[i].stsz++; - if (samplesSize != info.sampleSize) { - samplesSize = info.sampleSize; - sampleSizeChanges++; - } - - if (info.hasCompositionTimeOffset) { - if (info.sampleCompositionTimeOffset != compositionOffsetLast) { - tablesInfo[i].ctts++; - compositionOffsetLast = info.sampleCompositionTimeOffset; - } - } - - totalSampleSize += info.sampleSize; - } - } - - if (defaultMediaTime[i] < 1) { - defaultMediaTime[i] = defaultSampleDuration[i]; - } - - readers[i].rewind(); - - if (singleSampleBuffer > 0) { - initChunkTables(tablesInfo[i], singleSampleBuffer, singleSampleBuffer); - } else { - initChunkTables(tablesInfo[i], SAMPLES_PER_CHUNK_INIT, SAMPLES_PER_CHUNK); - } - - sampleCount[i] = tablesInfo[i].stsz; - - if (sampleSizeChanges == 1) { - tablesInfo[i].stsz = 0; - tablesInfo[i].stszDefault = samplesSize; - } else { - tablesInfo[i].stszDefault = 0; - } - - if (tablesInfo[i].stss == tablesInfo[i].stsz) { - tablesInfo[i].stss = -1; // for audio tracks (all samples are keyframes) - } - - // ensure track duration - if (tracks[i].trak.tkhd.duration < 1) { - tracks[i].trak.tkhd.duration = sampleExtra[i]; // this never should happen - } - } - - - final boolean is64 = read > THRESHOLD_FOR_CO64; - - // calculate the moov size - final int auxSize = makeMoov(defaultMediaTime, tablesInfo, is64); - - if (auxSize < THRESHOLD_MOOV_LENGTH) { - auxBuffer = ByteBuffer.allocate(auxSize); // cache moov in the memory - } - - moovSimulation = false; - writeOffset = 0; - - final int ftypSize = makeFtyp(); - - // reserve moov space in the output stream - if (auxSize > 0) { - int length = auxSize; - final byte[] buffer = new byte[64 * 1024]; // 64 KiB - while (length > 0) { - final int count = Math.min(length, buffer.length); - outWrite(buffer, count); - length -= count; - } - } - - if (auxBuffer == null) { - outSeek(ftypSize); - } - - // tablesInfo contains row counts - // and after returning from makeMoov() will contain those table offsets - makeMoov(defaultMediaTime, tablesInfo, is64); - - // write tables: stts stsc sbgp - // reset for ctts table: sampleCount sampleExtra - for (int i = 0; i < readers.length; i++) { - writeEntryArray(tablesInfo[i].stts, 2, sampleCount[i], defaultSampleDuration[i]); - writeEntryArray(tablesInfo[i].stsc, tablesInfo[i].stscBEntries.length, - tablesInfo[i].stscBEntries); - tablesInfo[i].stscBEntries = null; - if (tablesInfo[i].ctts > 0) { - sampleCount[i] = 1; // the index is not base zero - sampleExtra[i] = -1; - } - if (tablesInfo[i].sbgp > 0) { - writeEntryArray(tablesInfo[i].sbgp, 1, sampleCount[i]); - } - } - - if (auxBuffer == null) { - outRestore(); - } - - outWrite(makeMdat(totalSampleSize, is64)); - - final int[] sampleIndex = new int[readers.length]; - final int[] sizes = - new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; - final int[] sync = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; - - int written = readers.length; - while (written > 0) { - written = 0; - - for (int i = 0; i < readers.length; i++) { - if (sampleIndex[i] < 0) { - continue; // track is done - } - - final long chunkOffset = writeOffset; - int syncCount = 0; - final int limit; - if (singleSampleBuffer > 0) { - limit = singleSampleBuffer; - } else { - limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; - } - - int j = 0; - for (; j < limit; j++) { - final Mp4DashSample sample = getNextSample(i); - - if (sample == null) { - if (tablesInfo[i].ctts > 0 && sampleExtra[i] >= 0) { - writeEntryArray(tablesInfo[i].ctts, 1, sampleCount[i], - sampleExtra[i]); // flush last entries - outRestore(); - } - sampleIndex[i] = -1; - break; - } - - sampleIndex[i]++; - - if (tablesInfo[i].ctts > 0) { - if (sample.info.sampleCompositionTimeOffset == sampleExtra[i]) { - sampleCount[i]++; - } else { - if (sampleExtra[i] >= 0) { - tablesInfo[i].ctts = writeEntryArray(tablesInfo[i].ctts, 2, - sampleCount[i], sampleExtra[i]); - outRestore(); - } - sampleCount[i] = 1; - sampleExtra[i] = sample.info.sampleCompositionTimeOffset; - } - } - - if (tablesInfo[i].stss > 0 && sample.info.isKeyframe) { - sync[syncCount++] = sampleIndex[i]; - } - - if (tablesInfo[i].stsz > 0) { - sizes[j] = sample.data.length; - } - - outWrite(sample.data, sample.data.length); - } - - if (j > 0) { - written++; - - if (tablesInfo[i].stsz > 0) { - tablesInfo[i].stsz = writeEntryArray(tablesInfo[i].stsz, j, sizes); - } - - if (syncCount > 0) { - tablesInfo[i].stss = writeEntryArray(tablesInfo[i].stss, syncCount, sync); - } - - if (tablesInfo[i].stco > 0) { - if (is64) { - tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); - } else { - tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, - (int) chunkOffset); - } - } - - outRestore(); - } - } - } - - if (auxBuffer != null) { - // dump moov - outSeek(ftypSize); - outStream.write(auxBuffer.array(), 0, auxBuffer.capacity()); - auxBuffer = null; - } - } - - private Mp4DashSample getNextSample(final int track) throws IOException { - if (readersChunks[track] == null) { - readersChunks[track] = readers[track].getNextChunk(false); - if (readersChunks[track] == null) { - return null; // EOF reached - } - } - - final Mp4DashSample sample = readersChunks[track].getNextSample(); - if (sample == null) { - readersChunks[track] = null; - return getNextSample(track); - } else { - return sample; - } - } - - - private int writeEntry64(final int offset, final long value) throws IOException { - outBackup(); - - auxSeek(offset); - auxWrite(ByteBuffer.allocate(8).putLong(value).array()); - - return offset + 8; - } - - private int writeEntryArray(final int offset, final int count, final int... values) - throws IOException { - outBackup(); - - auxSeek(offset); - - final int size = count * 4; - final ByteBuffer buffer = ByteBuffer.allocate(size); - - for (int i = 0; i < count; i++) { - buffer.putInt(values[i]); - } - - auxWrite(buffer.array()); - - return offset + size; - } - - private void outBackup() { - if (auxBuffer == null && lastWriteOffset < 0) { - lastWriteOffset = writeOffset; - } - } - - /** - * Restore to the previous position before the first call to writeEntry64() - * or writeEntryArray() methods. - */ - private void outRestore() throws IOException { - if (lastWriteOffset > 0) { - outSeek(lastWriteOffset); - lastWriteOffset = -1; - } - } - - private void initChunkTables(final TablesInfo tables, final int firstCount, - final int successiveCount) { - // tables.stsz holds amount of samples of the track (total) - final int totalSamples = (tables.stsz - firstCount); - final float chunkAmount = totalSamples / (float) successiveCount; - final int remainChunkOffset = (int) Math.ceil(chunkAmount); - final boolean remain = remainChunkOffset != (int) chunkAmount; - int index = 0; - - tables.stsc = 1; - if (firstCount != successiveCount) { - tables.stsc++; - } - if (remain) { - tables.stsc++; - } - - // stsc_table_entry = [first_chunk, samples_per_chunk, sample_description_index] - tables.stscBEntries = new int[tables.stsc * 3]; - tables.stco = remainChunkOffset + 1; // total entries in chunk offset box - - tables.stscBEntries[index++] = 1; - tables.stscBEntries[index++] = firstCount; - tables.stscBEntries[index++] = 1; - - if (firstCount != successiveCount) { - tables.stscBEntries[index++] = 2; - tables.stscBEntries[index++] = successiveCount; - tables.stscBEntries[index++] = 1; - } - - if (remain) { - tables.stscBEntries[index++] = remainChunkOffset + 1; - tables.stscBEntries[index++] = totalSamples % successiveCount; - tables.stscBEntries[index] = 1; - } - } - - private void outWrite(final byte[] buffer) throws IOException { - outWrite(buffer, buffer.length); - } - - private void outWrite(final byte[] buffer, final int count) throws IOException { - writeOffset += count; - outStream.write(buffer, 0, count); - } - - private void outSeek(final long offset) throws IOException { - if (outStream.canSeek()) { - outStream.seek(offset); - writeOffset = offset; - } else if (outStream.canRewind()) { - outStream.rewind(); - writeOffset = 0; - outSkip(offset); - } else { - throw new IOException("cannot seek or rewind the output stream"); - } - } - - private void outSkip(final long amount) throws IOException { - outStream.skip(amount); - writeOffset += amount; - } - - private int lengthFor(final int offset) throws IOException { - final int size = auxOffset() - offset; - - if (moovSimulation) { - return size; - } - - auxSeek(offset); - auxWrite(size); - auxSkip(size - 4); - - return size; - } - - private int make(final int type, final int extra, final int columns, final int rows) - throws IOException { - final byte base = 16; - final int size = columns * rows * 4; - int total = size + base; - int offset = auxOffset(); - - if (extra >= 0) { - total += 4; - } - - auxWrite(ByteBuffer.allocate(12) - .putInt(total) - .putInt(type) - .putInt(0x00)// default version & flags - .array() - ); - - if (extra >= 0) { - offset += 4; - auxWrite(extra); - } - - auxWrite(rows); - auxSkip(size); - - return offset + base; - } - - private void auxWrite(final int value) throws IOException { - auxWrite(ByteBuffer.allocate(4) - .putInt(value) - .array() - ); - } - - private void auxWrite(final byte[] buffer) throws IOException { - if (moovSimulation) { - writeOffset += buffer.length; - } else if (auxBuffer == null) { - outWrite(buffer, buffer.length); - } else { - auxBuffer.put(buffer); - } - } - - private void auxSeek(final int offset) throws IOException { - if (moovSimulation) { - writeOffset = offset; - } else if (auxBuffer == null) { - outSeek(offset); - } else { - auxBuffer.position(offset); - } - } - - private void auxSkip(final int amount) throws IOException { - if (moovSimulation) { - writeOffset += amount; - } else if (auxBuffer == null) { - outSkip(amount); - } else { - auxBuffer.position(auxBuffer.position() + amount); - } - } - - private int auxOffset() { - return auxBuffer == null ? (int) writeOffset : auxBuffer.position(); - } - - private int makeFtyp() throws IOException { - int size = 16 + (compatibleBrands.size() * 4); - if (overrideMainBrand != 0) { - size += 4; - } - - final ByteBuffer buffer = ByteBuffer.allocate(size); - buffer.putInt(size); - buffer.putInt(0x66747970); // "ftyp" - - if (overrideMainBrand == 0) { - buffer.putInt(0x6D703432); // mayor brand "mp42" - buffer.putInt(512); // default minor version - } else { - buffer.putInt(overrideMainBrand); - buffer.putInt(0); - buffer.putInt(0x6D703432); // "mp42" compatible brand - } - - for (final Integer brand : compatibleBrands) { - buffer.putInt(brand); // compatible brand - } - - outWrite(buffer.array()); - - return size; - } - - private byte[] makeMdat(final long refSize, final boolean is64) { - long size = refSize; - if (is64) { - size += 16; - } else { - size += 8; - } - - final ByteBuffer buffer = ByteBuffer.allocate(is64 ? 16 : 8) - .putInt(is64 ? 0x01 : (int) size) - .putInt(0x6D646174); // mdat - - if (is64) { - buffer.putLong(size); - } - - return buffer.array(); - } - - private void makeMvhd(final long longestTrack) throws IOException { - auxWrite(new byte[]{ - 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 - }); - auxWrite(ByteBuffer.allocate(28) - .putLong(time) - .putLong(time) - .putInt(DEFAULT_TIMESCALE) - .putLong(longestTrack) - .array() - ); - - auxWrite(new byte[]{ - 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, // default volume and rate - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved values - // default matrix - 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x40, 0x00, 0x00, 0x00 - }); - auxWrite(new byte[24]); // predefined - auxWrite(ByteBuffer.allocate(4) - .putInt(tracks.length + 1) - .array() - ); - } - - private int makeMoov(final int[] defaultMediaTime, final TablesInfo[] tablesInfo, - final boolean is64) throws RuntimeException, IOException { - final int start = auxOffset(); - - auxWrite(new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 - }); - - long longestTrack = 0; - final long[] durations = new long[tracks.length]; - - for (int i = 0; i < durations.length; i++) { - durations[i] = (long) Math.ceil( - ((double) tracks[i].trak.tkhd.duration / tracks[i].trak.mdia.mdhdTimeScale) - * DEFAULT_TIMESCALE); - - if (durations[i] > longestTrack) { - longestTrack = durations[i]; - } - } - - makeMvhd(longestTrack); - - for (int i = 0; i < tracks.length; i++) { - if (tracks[i].trak.tkhd.matrix.length != 36) { - throw - new RuntimeException("bad track matrix length (expected 36) in track n°" + i); - } - makeTrak(i, durations[i], defaultMediaTime[i], tablesInfo[i], is64); - } - - return lengthFor(start); - } - - private void makeTrak(final int index, final long duration, final int defaultMediaTime, - final TablesInfo tables, final boolean is64) throws IOException { - final int start = auxOffset(); - - auxWrite(new byte[]{ - // trak header - 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B, - // tkhd header - 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 - }); - - final ByteBuffer buffer = ByteBuffer.allocate(48); - buffer.putLong(time); - buffer.putLong(time); - buffer.putInt(index + 1); - buffer.position(24); - buffer.putLong(duration); - buffer.position(40); - buffer.putShort(tracks[index].trak.tkhd.bLayer); - buffer.putShort(tracks[index].trak.tkhd.bAlternateGroup); - buffer.putShort(tracks[index].trak.tkhd.bVolume); - auxWrite(buffer.array()); - - auxWrite(tracks[index].trak.tkhd.matrix); - auxWrite(ByteBuffer.allocate(8) - .putInt(tracks[index].trak.tkhd.bWidth) - .putInt(tracks[index].trak.tkhd.bHeight) - .array() - ); - - auxWrite(new byte[]{ - 0x00, 0x00, 0x00, 0x24, 0x65, 0x64, 0x74, 0x73, // edts header - 0x00, 0x00, 0x00, 0x1C, 0x65, 0x6C, 0x73, 0x74, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // elst header - }); - - final int bMediaRate; - final int mediaTime; - - if (tracks[index].trak.edstElst == null) { - // is a audio track ¿is edst/elst optional for audio tracks? - mediaTime = 0x00; // ffmpeg set this value as zero, instead of defaultMediaTime - bMediaRate = 0x00010000; - } else { - mediaTime = (int) tracks[index].trak.edstElst.mediaTime; - bMediaRate = tracks[index].trak.edstElst.bMediaRate; - } - - auxWrite(ByteBuffer - .allocate(12) - .putInt((int) duration) - .putInt(mediaTime) - .putInt(bMediaRate) - .array() - ); - - makeMdia(tracks[index].trak.mdia, tables, is64, tracks[index].kind == TrackKind.Audio); - - lengthFor(start); - } - - private void makeMdia(final Mdia mdia, final TablesInfo tablesInfo, final boolean is64, - final boolean isAudio) throws IOException { - final int startMdia = auxOffset(); - auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x61}); // mdia - auxWrite(mdia.mdhd); - auxWrite(makeHdlr(mdia.hdlr)); - - final int startMinf = auxOffset(); - auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x69, 0x6E, 0x66}); // minf - auxWrite(mdia.minf.mhd); - auxWrite(mdia.minf.dinf); - - final int startStbl = auxOffset(); - auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x73, 0x74, 0x62, 0x6C}); // stbl - auxWrite(mdia.minf.stblStsd); - - // - // In audio tracks the following tables is not required: ssts ctts - // And stsz can be empty if has a default sample size - // - if (moovSimulation) { - make(0x73747473, -1, 2, 1); // stts - if (tablesInfo.stss > 0) { - make(0x73747373, -1, 1, tablesInfo.stss); - } - if (tablesInfo.ctts > 0) { - make(0x63747473, -1, 2, tablesInfo.ctts); - } - make(0x73747363, -1, 3, tablesInfo.stsc); - make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); - make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); - } else { - tablesInfo.stts = make(0x73747473, -1, 2, 1); - if (tablesInfo.stss > 0) { - tablesInfo.stss = make(0x73747373, -1, 1, tablesInfo.stss); - } - if (tablesInfo.ctts > 0) { - tablesInfo.ctts = make(0x63747473, -1, 2, tablesInfo.ctts); - } - tablesInfo.stsc = make(0x73747363, -1, 3, tablesInfo.stsc); - tablesInfo.stsz = make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); - tablesInfo.stco = make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, - tablesInfo.stco); - } - - if (isAudio) { - auxWrite(makeSgpd()); - tablesInfo.sbgp = makeSbgp(); // during simulation the returned offset is ignored - } - - lengthFor(startStbl); - lengthFor(startMinf); - lengthFor(startMdia); - } - - private byte[] makeHdlr(final Hdlr hdlr) { - final ByteBuffer buffer = ByteBuffer.wrap(new byte[]{ - 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, // hdlr - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00// null string character - }); - - buffer.position(12); - buffer.putInt(hdlr.type); - buffer.putInt(hdlr.subType); - buffer.put(hdlr.bReserved); // always is a zero array - - return buffer.array(); - } - - private int makeSbgp() throws IOException { - final int offset = auxOffset(); - - auxWrite(new byte[] { - 0x00, 0x00, 0x00, 0x1C, // box size - 0x73, 0x62, 0x67, 0x70, // "sbpg" - 0x00, 0x00, 0x00, 0x00, // default box flags - 0x72, 0x6F, 0x6C, 0x6C, // group type "roll" - 0x00, 0x00, 0x00, 0x01, // group table size - 0x00, 0x00, 0x00, 0x00, // group[0] total samples (to be set later) - 0x00, 0x00, 0x00, 0x01 // group[0] description index - }); - - return offset + 0x14; - } - - private byte[] makeSgpd() { - /* - * Sample Group Description Box - * - * ¿whats does? - * the table inside of this box gives information about the - * characteristics of sample groups. The descriptive information is any other - * information needed to define or characterize the sample group. - * - * ¿is replicable this box? - * NO due lacks of documentation about this box but... - * most of m4a encoders and ffmpeg uses this box with dummy values (same values) - */ - - final ByteBuffer buffer = ByteBuffer.wrap(new byte[] { - 0x00, 0x00, 0x00, 0x1A, // box size - 0x73, 0x67, 0x70, 0x64, // "sgpd" - 0x01, 0x00, 0x00, 0x00, // box flags (unknown flag sets) - 0x72, 0x6F, 0x6C, 0x6C, // ¿¿group type?? - 0x00, 0x00, 0x00, 0x02, // ¿¿?? - 0x00, 0x00, 0x00, 0x01, // ¿¿?? - (byte) 0xFF, (byte) 0xFF // ¿¿?? - }); - - return buffer.array(); - } - - static class TablesInfo { - int stts; - int stsc; - int[] stscBEntries; - int ctts; - int stsz; - int stszDefault; - int stss; - int stco; - int sbgp; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java deleted file mode 100644 index 7cdc84e22..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ /dev/null @@ -1,501 +0,0 @@ -package org.schabi.newpipe.streams; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.streams.WebMReader.Cluster; -import org.schabi.newpipe.streams.WebMReader.Segment; -import org.schabi.newpipe.streams.WebMReader.SimpleBlock; -import org.schabi.newpipe.streams.WebMReader.WebMTrack; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.Closeable; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -/** - * @author kapodamy - */ -public class OggFromWebMWriter implements Closeable { - private static final byte FLAG_UNSET = 0x00; - //private static final byte FLAG_CONTINUED = 0x01; - private static final byte FLAG_FIRST = 0x02; - private static final byte FLAG_LAST = 0x04; - - private static final byte HEADER_CHECKSUM_OFFSET = 22; - private static final byte HEADER_SIZE = 27; - - private static final int TIME_SCALE_NS = 1000000000; - - private boolean done = false; - private boolean parsed = false; - - private final SharpStream source; - private final SharpStream output; - - private int sequenceCount = 0; - private final int streamId; - private byte packetFlag = FLAG_FIRST; - - private WebMReader webm = null; - private WebMTrack webmTrack = null; - private Segment webmSegment = null; - private Cluster webmCluster = null; - private SimpleBlock webmBlock = null; - - private long webmBlockLastTimecode = 0; - private long webmBlockNearDuration = 0; - - private short segmentTableSize = 0; - private final byte[] segmentTable = new byte[255]; - private long segmentTableNextTimestamp = TIME_SCALE_NS; - - private final int[] crc32Table = new int[256]; - private final StreamInfo streamInfo; - - public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target, - @Nullable final StreamInfo streamInfo) { - if (!source.canRead() || !source.canRewind()) { - throw new IllegalArgumentException("source stream must be readable and allows seeking"); - } - if (!target.canWrite() || !target.canRewind()) { - throw new IllegalArgumentException("output stream must be writable and allows seeking"); - } - - this.source = source; - this.output = target; - this.streamInfo = streamInfo; - - this.streamId = (int) System.currentTimeMillis(); - - populateCrc32Table(); - } - - public boolean isDone() { - return done; - } - - public boolean isParsed() { - return parsed; - } - - public WebMTrack[] getTracksFromSource() throws IllegalStateException { - if (!parsed) { - throw new IllegalStateException("source must be parsed first"); - } - - return webm.getAvailableTracks(); - } - - public void parseSource() throws IOException, IllegalStateException { - if (done) { - throw new IllegalStateException("already done"); - } - if (parsed) { - throw new IllegalStateException("already parsed"); - } - - try { - webm = new WebMReader(source); - webm.parse(); - webmSegment = webm.getNextSegment(); - } finally { - parsed = true; - } - } - - public void selectTrack(final int trackIndex) throws IOException { - if (!parsed) { - throw new IllegalStateException("source must be parsed first"); - } - if (done) { - throw new IOException("already done"); - } - if (webmTrack != null) { - throw new IOException("tracks already selected"); - } - - switch (webm.getAvailableTracks()[trackIndex].kind) { - case Audio: - case Video: - break; - default: - throw new UnsupportedOperationException("the track must an audio or video stream"); - } - - try { - webmTrack = webm.selectTrack(trackIndex); - } finally { - parsed = true; - } - } - - @Override - public void close() throws IOException { - done = true; - parsed = true; - - webmTrack = null; - webm = null; - - if (!output.isClosed()) { - output.flush(); - } - - source.close(); - output.close(); - } - - public void build() throws IOException { - final float resolution; - SimpleBlock bloq; - final ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255)); - final ByteBuffer page = ByteBuffer.allocate(64 * 1024); - - header.order(ByteOrder.LITTLE_ENDIAN); - - /* step 1: get the amount of frames per seconds */ - switch (webmTrack.kind) { - case Audio: - resolution = getSampleFrequencyFromTrack(webmTrack.bMetadata); - if (resolution == 0f) { - throw new RuntimeException("cannot get the audio sample rate"); - } - break; - case Video: - // WARNING: untested - if (webmTrack.defaultDuration == 0) { - throw new RuntimeException("missing default frame time"); - } - resolution = 1000f / ((float) webmTrack.defaultDuration - / webmSegment.info.timecodeScale); - break; - default: - throw new RuntimeException("not implemented"); - } - - /* step 2: create packet with code init data */ - if (webmTrack.codecPrivate != null) { - addPacketSegment(webmTrack.codecPrivate.length); - makePacketheader(0x00, header, webmTrack.codecPrivate); - write(header); - output.write(webmTrack.codecPrivate); - } - - /* step 3: create packet with metadata */ - final byte[] buffer = makeMetadata(); - if (buffer != null) { - addPacketSegment(buffer.length); - makePacketheader(0x00, header, buffer); - write(header); - output.write(buffer); - } - - /* step 4: calculate amount of packets */ - while (webmSegment != null) { - bloq = getNextBlock(); - - if (bloq != null && addPacketSegment(bloq)) { - final int pos = page.position(); - //noinspection ResultOfMethodCallIgnored - bloq.data.read(page.array(), pos, bloq.dataSize); - page.position(pos + bloq.dataSize); - continue; - } - - // calculate the current packet duration using the next block - double elapsedNs = webmTrack.codecDelay; - - if (bloq == null) { - packetFlag = FLAG_LAST; // note: if the flag is FLAG_CONTINUED, is changed - elapsedNs += webmBlockLastTimecode; - - if (webmTrack.defaultDuration > 0) { - elapsedNs += webmTrack.defaultDuration; - } else { - // hardcoded way, guess the sample duration - elapsedNs += webmBlockNearDuration; - } - } else { - elapsedNs += bloq.absoluteTimeCodeNs; - } - - // get the sample count in the page - elapsedNs = elapsedNs / TIME_SCALE_NS; - elapsedNs = Math.ceil(elapsedNs * resolution); - - // create header and calculate page checksum - int checksum = makePacketheader((long) elapsedNs, header, null); - checksum = calcCrc32(checksum, page.array(), page.position()); - - header.putInt(HEADER_CHECKSUM_OFFSET, checksum); - - // dump data - write(header); - write(page); - - webmBlock = bloq; - } - } - - private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer, - final byte[] immediatePage) { - short length = HEADER_SIZE; - - buffer.putInt(0x5367674f); // "OggS" binary string in little-endian - buffer.put((byte) 0x00); // version - buffer.put(packetFlag); // type - - buffer.putLong(granPos); // granulate position - - buffer.putInt(streamId); // bitstream serial number - buffer.putInt(sequenceCount++); // page sequence number - - buffer.putInt(0x00); // page checksum - - buffer.put((byte) segmentTableSize); // segment table - buffer.put(segmentTable, 0, segmentTableSize); // segment size - - length += segmentTableSize; - - clearSegmentTable(); // clear segment table for next header - - int checksumCrc32 = calcCrc32(0x00, buffer.array(), length); - - if (immediatePage != null) { - checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.length); - buffer.putInt(HEADER_CHECKSUM_OFFSET, checksumCrc32); - segmentTableNextTimestamp -= TIME_SCALE_NS; - } - - return checksumCrc32; - } - - @Nullable - private byte[] makeMetadata() { - if (DEBUG) { - Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId); - } - - if ("A_OPUS".equals(webmTrack.codecId)) { - final var metadata = new ArrayList>(); - if (streamInfo != null) { - metadata.add(Pair.create("COMMENT", streamInfo.getUrl())); - metadata.add(Pair.create("GENRE", streamInfo.getCategory())); - metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName())); - metadata.add(Pair.create("TITLE", streamInfo.getName())); - metadata.add(Pair.create("DATE", streamInfo - .getUploadDate() - .getLocalDateTime() - .format(DateTimeFormatter.ISO_DATE))); - } - - if (DEBUG) { - Log.d("OggFromWebMWriter", "Creating metadata header with this data:"); - metadata.forEach(p -> { - Log.d("OggFromWebMWriter", p.first + "=" + p.second); - }); - } - - return makeOpusTagsHeader(metadata); - } else if ("A_VORBIS".equals(webmTrack.codecId)) { - return new byte[]{ - 0x03, // ¿¿¿??? - 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string - 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) - 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) - }; - } - - // not implemented for the desired codec - return null; - } - - /** - * This creates a single metadata tag for use in opus metadata headers. It contains the four - * byte string length field and includes the string as-is. This cannot be used independently, - * but must follow a proper "OpusTags" header. - * - * @param pair A key-value pair in the format "KEY=some value" - * @return The binary data of the encoded metadata tag - */ - private static byte[] makeOpusMetadataTag(final Pair pair) { - final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim(); - - final var bytes = keyValue.getBytes(); - final var buf = ByteBuffer.allocate(4 + bytes.length); - buf.order(ByteOrder.LITTLE_ENDIAN); - buf.putInt(bytes.length); - buf.put(bytes); - return buf.array(); - } - - /** - * This returns a complete "OpusTags" header, created from the provided metadata tags. - *

- * You probably want to use makeOpusMetadata(), which uses this function to create - * a header with sensible metadata filled in. - * - * @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping - * from one key to multiple values. - * @return The binary header - */ - private static byte[] makeOpusTagsHeader(final List> keyValueLines) { - final var tags = keyValueLines - .stream() - .filter(p -> !p.second.isBlank()) - .map(OggFromWebMWriter::makeOpusMetadataTag) - .collect(Collectors.toUnmodifiableList()); - - final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length)); - - // Fixed header fields + dynamic fields - final var byteCount = 16 + tagsBytes; - - final var head = ByteBuffer.allocate(byteCount); - head.order(ByteOrder.LITTLE_ENDIAN); - head.put(new byte[]{ - 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string - 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 - }); - head.putInt(tags.size()); // 4 bytes for tag count - tags.forEach(head::put); // dynamic amount of tag bytes - - return head.array(); - } - - private void write(final ByteBuffer buffer) throws IOException { - output.write(buffer.array(), 0, buffer.position()); - buffer.position(0); - } - - @Nullable - private SimpleBlock getNextBlock() throws IOException { - SimpleBlock res; - - if (webmBlock != null) { - res = webmBlock; - webmBlock = null; - return res; - } - - if (webmSegment == null) { - webmSegment = webm.getNextSegment(); - if (webmSegment == null) { - return null; // no more blocks in the selected track - } - } - - if (webmCluster == null) { - webmCluster = webmSegment.getNextCluster(); - if (webmCluster == null) { - webmSegment = null; - return getNextBlock(); - } - } - - res = webmCluster.getNextSimpleBlock(); - if (res == null) { - webmCluster = null; - return getNextBlock(); - } - - webmBlockNearDuration = res.absoluteTimeCodeNs - webmBlockLastTimecode; - webmBlockLastTimecode = res.absoluteTimeCodeNs; - - return res; - } - - private float getSampleFrequencyFromTrack(final byte[] bMetadata) { - // hardcoded way - final ByteBuffer buffer = ByteBuffer.wrap(bMetadata); - - while (buffer.remaining() >= 6) { - final int id = buffer.getShort() & 0xFFFF; - if (id == 0x0000B584) { - return buffer.getFloat(); - } - } - - return 0.0f; - } - - private void clearSegmentTable() { - segmentTableNextTimestamp += TIME_SCALE_NS; - packetFlag = FLAG_UNSET; - segmentTableSize = 0; - } - - private boolean addPacketSegment(final SimpleBlock block) { - final long timestamp = block.absoluteTimeCodeNs + webmTrack.codecDelay; - - if (timestamp >= segmentTableNextTimestamp) { - return false; - } - - return addPacketSegment(block.dataSize); - } - - private boolean addPacketSegment(final int size) { - if (size > 65025) { - throw new UnsupportedOperationException("page size cannot be larger than 65025"); - } - - int available = (segmentTable.length - segmentTableSize) * 255; - final boolean extra = (size % 255) == 0; - - if (extra) { - // add a zero byte entry in the table - // required to indicate the sample size is multiple of 255 - available -= 255; - } - - // check if possible add the segment, without overflow the table - if (available < size) { - return false; // not enough space on the page - } - - for (int seg = size; seg > 0; seg -= 255) { - segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255); - } - - if (extra) { - segmentTable[segmentTableSize++] = 0x00; - } - - return true; - } - - private void populateCrc32Table() { - for (int i = 0; i < 0x100; i++) { - int crc = i << 24; - for (int j = 0; j < 8; j++) { - final long b = crc >>> 31; - crc <<= 1; - crc ^= (int) (0x100000000L - b) & 0x04c11db7; - } - crc32Table[i] = crc; - } - } - - private int calcCrc32(final int initialCrc, final byte[] buffer, final int size) { - int crc = initialCrc; - for (int i = 0; i < size; i++) { - final int reg = (crc >>> 24) & 0xff; - crc = (crc << 8) ^ crc32Table[reg ^ (buffer[i] & 0xff)]; - } - - return crc; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java deleted file mode 100644 index 8c8dc175b..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java +++ /dev/null @@ -1,326 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.nodes.Node; -import org.jsoup.nodes.TextNode; -import org.jsoup.parser.Parser; -import org.jsoup.select.Elements; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; - -/** - * Converts TTML subtitles to SRT format. - * - * References: - * - TTML 2.0 (W3C): https://www.w3.org/TR/ttml2/ - * - SRT format: https://en.wikipedia.org/wiki/SubRip - */ -public class SrtFromTtmlWriter { - private static final String NEW_LINE = "\r\n"; - - private final SharpStream out; - private final boolean ignoreEmptyFrames; - private final Charset charset = StandardCharsets.UTF_8; - - // According to the SubRip (.srt) specification, subtitle - // numbering must start from 1. - // Some players accept 0 or even negative indices, - // but to ensure compliance we start at 1. - private int frameIndex = 1; - - public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) { - this.out = out; - this.ignoreEmptyFrames = ignoreEmptyFrames; - } - - private static String getTimestamp(final Element frame, final String attr) { - return frame - .attr(attr) - .replace('.', ','); // SRT subtitles uses comma as decimal separator - } - - private void writeFrame(final String begin, final String end, final StringBuilder text) - throws IOException { - writeString(String.valueOf(frameIndex)); - frameIndex += 1; - writeString(NEW_LINE); - writeString(begin); - writeString(" --> "); - writeString(end); - writeString(NEW_LINE); - writeString(text.toString()); - writeString(NEW_LINE); - writeString(NEW_LINE); - } - - private void writeString(final String text) throws IOException { - out.write(text.getBytes(charset)); - } - - /** - * Decode XML or HTML entities into their actual (literal) characters. - * - * TTML is XML-based, so text nodes may contain escaped entities - * instead of direct characters. For example: - * - * "&" → "&" - * "<" → "<" - * ">" → ">" - * " " → "\t" (TAB) - * " " ( ) → "\n" (LINE FEED) - * - * XML files cannot contain characters like "<", ">", "&" directly, - * so they must be represented using their entity-encoded forms. - * - * Jsoup sometimes leaves nested or encoded entities unresolved - * (e.g. inside

text nodes in TTML files), so this function - * acts as a final “safety net” to ensure all entities are decoded - * before further normalization. - * - * Character representation layers for reference: - * - Literal characters: <, >, & - * → appear in runtime/output text (e.g. final SRT output) - * - Escaped entities: <, >, & - * → appear in XML/HTML/TTML source files - * - Numeric entities:  , , - * → appear mainly in XML/TTML files (also valid in HTML) - * for non-printable or special characters - * - Unicode escapes: \u00A0 (Java/Unicode internal form) - * → appear only in Java source code (NOT valid in XML) - * - * XML entities include both named (&, <) and numeric - * ( ,  ) forms. - * - * @param encodedEntities The raw text fragment possibly containing - * encoded XML entities. - * @return A decoded string where all entities are replaced by their - * actual (literal) characters. - */ - private String decodeXmlEntities(final String encodedEntities) { - return Parser.unescapeEntities(encodedEntities, true); - } - - /** - * Handle rare XML entity characters like LF: (`\n`), - * CR: (`\r`) and CRLF: (`\r\n`). - * - * These are technically valid in TTML (XML allows them) - * but unusual in practice, since most TTML line breaks - * are represented as
tags instead. - * As a defensive approach, we normalize them: - * - * - Windows (\r\n), macOS (\r), and Unix (\n) → unified SRT NEW_LINE (\r\n) - * - * Although well-formed TTML normally encodes line breaks - * as
tags, some auto-generated or malformed TTML files - * may embed literal newline entities ( , ). This - * normalization ensures these cases render properly in SRT - * players instead of breaking the subtitle structure. - * - * @param text To be normalized text with actual characters. - * @return Unified SRT NEW_LINE converted from all kinds of line breaks. - */ - private String normalizeLineBreakForSrt(final String text) { - String cleaned = text; - - // NOTE: - // The order of newline replacements must NOT change, - // or duplicated line breaks (e.g. \r\n → \n\n) will occur. - cleaned = cleaned.replace("\r\n", "\n") - .replace("\r", "\n"); - - cleaned = cleaned.replace("\n", NEW_LINE); - - return cleaned; - } - - private String normalizeForSrt(final String actualText) { - String cleaned = actualText; - - // Replace NBSP "non-breaking space" (\u00A0) with regular space ' '(\u0020). - // - // Why: - // - Some viewers render NBSP(\u00A0) incorrectly: - // * MPlayer 1.5: shown as “??” - // * Linux command `cat -A`: displayed as control-like markers - // (M-BM-) - // * Acode (Android editor): displayed as visible replacement - // glyphs (red dots) - // - Other viewers show it as a normal space (e.g., VS Code 1.104.0, - // vlc 3.0.20, mpv 0.37.0, Totem 43.0) - // → Mixed rendering creates inconsistency and may confuse users. - // - // Details: - // - YouTube TTML subtitles use both regular spaces (\u0020) - // and non-breaking spaces (\u00A0). - // - SRT subtitles only support regular spaces (\u0020), - // so \u00A0 may cause display issues. - // - \u00A0 and \u0020 are visually identical (i.e., they both - // appear as spaces ' '), but they differ in Unicode encoding, - // and NBSP (\u00A0) renders differently in different viewers. - // - SRT is a plain-text format and does not interpret - // "non-breaking" behavior. - // - // Conclusion: - // - Ensure uniform behavior, so replace it to a regular space - // without "non-breaking" behavior. - // - // References: - // - Unicode U+00A0 NBSP (Latin-1 Supplement): - // https://unicode.org/charts/PDF/U0080.pdf - cleaned = cleaned.replace('\u00A0', ' ') // Non-breaking space - .replace('\u202F', ' ') // Narrow no-break space - .replace('\u205F', ' ') // Medium mathematical space - .replace('\u3000', ' ') // Ideographic space - // \u2000 ~ \u200A are whitespace characters (e.g., - // en space, em space), replaced with regular space (\u0020). - .replaceAll("[\\u2000-\\u200A]", " "); // Whitespace characters - - // \u200B ~ \u200F are a range of non-spacing characters - // (e.g., zero-width space, zero-width non-joiner, etc.), - // which have no effect in *.SRT files and may cause - // display issues. - // These characters are invisible to the human eye, and - // they still exist in the encoding, so they need to be - // removed. - // After removal, the actual content becomes completely - // empty "", meaning there are no characters left, just - // an empty space, which helps avoid formatting issues - // in subtitles. - cleaned = cleaned.replaceAll("[\\u200B-\\u200F]", ""); // Non-spacing characters - - // Remove control characters (\u0000 ~ \u001F, except - // \n, \r, \t). - // - These are ASCII C0 control codes (e.g. \u0001 SOH, - // \u0008 BS, \u001F US), invisible and irrelevant in - // subtitles, may cause square boxes (?) in players. - // - Reference: - // Unicode Basic Latin (https://unicode.org/charts/PDF/U0000.pdf) - // ASCII Control (https://en.wikipedia.org/wiki/ASCII#Control_characters) - cleaned = cleaned.replaceAll("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", ""); - - // Reasoning: - // - subtitle files generally don't require tabs for alignment. - // - Tabs can be displayed with varying widths across different - // editors or platforms, which may cause display issues. - // - Replace it with a single space for consistent display - // across different editors or platforms. - cleaned = cleaned.replace('\t', ' '); - - cleaned = normalizeLineBreakForSrt(cleaned); - - return cleaned; - } - - private String sanitizeFragment(final String raw) { - if (null == raw) { - return ""; - } - - final String actualCharacters = decodeXmlEntities(raw); - - final String srtSafeText = normalizeForSrt(actualCharacters); - - return srtSafeText; - } - - // Recursively process all child nodes to ensure text inside - // nested tags (e.g., ) is also extracted. - private void traverseChildNodesForNestedTags(final Node parent, - final StringBuilder text) { - for (final Node child : parent.childNodes()) { - extractText(child, text); - } - } - - // CHECKSTYLE:OFF checkstyle:JavadocStyle - // checkstyle does not understand that span tags are inside a code block - /** - *

Recursive method to extract text from all nodes.

- *

- * This method processes {@link TextNode}s and {@code
} tags, - * recursively extracting text from nested tags - * (e.g. extracting text from nested {@code } tags). - * Newlines are added for {@code
} tags. - *

- * @param node the current node to process - * @param text the {@link StringBuilder} to append the extracted text to - */ - // -------------------------------------------------------------------- - // [INTERNAL NOTE] TTML text layer explanation - // - // TTML parsing involves multiple text "layers": - // 1. Raw XML entities (e.g., <,  ) are decoded by Jsoup. - // 2. extractText() works on DOM TextNodes (already parsed strings). - // 3. sanitizeFragment() decodes remaining entities and fixes - // Unicode quirks. - // 4. normalizeForSrt() ensures literal text is safe for SRT output. - // - // In short: - // Jsoup handles XML-level syntax, - // our code handles text-level normalization for subtitles. - // -------------------------------------------------------------------- - private void extractText(final Node node, final StringBuilder text) { - if (node instanceof TextNode textNode) { - String rawTtmlFragment = textNode.getWholeText(); - String srtContent = sanitizeFragment(rawTtmlFragment); - text.append(srtContent); - } else if (node instanceof Element element) { - //
is a self-closing HTML tag used to insert a line break. - if (element.tagName().equalsIgnoreCase("br")) { - // Add a newline for
tags - text.append(NEW_LINE); - } - } - - traverseChildNodesForNestedTags(node, text); - } - // CHECKSTYLE:ON - - public void build(final SharpStream ttml) throws IOException { - /* - * TTML parser with BASIC support - * multiple CUE is not supported - * styling is not supported - * tag timestamps (in auto-generated subtitles) are not supported, maybe in the future - * also TimestampTagOption enum is not applicable - * Language parsing is not supported - */ - - // parse XML - final byte[] buffer = new byte[(int) ttml.available()]; - ttml.read(buffer); - final Document doc = Jsoup.parse(new ByteArrayInputStream(buffer), "UTF-8", "", - Parser.xmlParser()); - - final StringBuilder text = new StringBuilder(128); - final Elements paragraphList = doc.select("body > div > p"); - - // check if has frames - if (paragraphList.isEmpty()) { - return; - } - - for (final Element paragraph : paragraphList) { - text.setLength(0); - - // Recursively extract text from all child nodes - extractText(paragraph, text); - - if (ignoreEmptyFrames && text.length() < 1) { - continue; - } - - final String begin = getTimestamp(paragraph, "begin"); - final String end = getTimestamp(paragraph, "end"); - - writeFrame(begin, end, text); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java deleted file mode 100644 index 678974cce..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ /dev/null @@ -1,537 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.EOFException; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.NoSuchElementException; - -/** - * - * @author kapodamy - */ -public class WebMReader { - private static final int ID_EMBL = 0x0A45DFA3; - private static final int ID_EMBL_READ_VERSION = 0x02F7; - private static final int ID_EMBL_DOC_TYPE = 0x0282; - private static final int ID_EMBL_DOC_TYPE_READ_VERSION = 0x0285; - - private static final int ID_SEGMENT = 0x08538067; - - private static final int ID_INFO = 0x0549A966; - private static final int ID_TIMECODE_SCALE = 0x0AD7B1; - private static final int ID_DURATION = 0x489; - - private static final int ID_TRACKS = 0x0654AE6B; - private static final int ID_TRACK_ENTRY = 0x2E; - private static final int ID_TRACK_NUMBER = 0x57; - private static final int ID_TRACK_TYPE = 0x03; - private static final int ID_CODEC_ID = 0x06; - private static final int ID_CODEC_PRIVATE = 0x23A2; - private static final int ID_VIDEO = 0x60; - private static final int ID_AUDIO = 0x61; - private static final int ID_DEFAULT_DURATION = 0x3E383; - private static final int ID_FLAG_LACING = 0x1C; - private static final int ID_CODEC_DELAY = 0x16AA; - private static final int ID_SEEK_PRE_ROLL = 0x16BB; - - private static final int ID_CLUSTER = 0x0F43B675; - private static final int ID_TIMECODE = 0x67; - private static final int ID_SIMPLE_BLOCK = 0x23; - private static final int ID_BLOCK = 0x21; - private static final int ID_GROUP_BLOCK = 0x20; - - - public enum TrackKind { - Audio/*2*/, Video/*1*/, Other - } - - private final DataReader stream; - private Segment segment; - private WebMTrack[] tracks; - private int selectedTrack; - private boolean done; - private boolean firstSegment; - - public WebMReader(final SharpStream source) { - this.stream = new DataReader(source); - } - - public void parse() throws IOException { - Element elem = readElement(ID_EMBL); - if (!readEbml(elem, 1, 2)) { - throw new UnsupportedOperationException("Unsupported EBML data (WebM)"); - } - ensure(elem); - - elem = untilElement(null, ID_SEGMENT); - if (elem == null) { - throw new IOException("Fragment element not found"); - } - segment = readSegment(elem, 0, true); - tracks = segment.tracks; - selectedTrack = -1; - done = false; - firstSegment = true; - } - - public WebMTrack[] getAvailableTracks() { - return tracks; - } - - public WebMTrack selectTrack(final int index) { - selectedTrack = index; - return tracks[index]; - } - - public Segment getNextSegment() throws IOException { - if (done) { - return null; - } - - if (firstSegment && segment != null) { - firstSegment = false; - return segment; - } - - ensure(segment.ref); - // WARNING: track cannot be the same or have different index in new segments - final Element elem = untilElement(null, ID_SEGMENT); - if (elem == null) { - done = true; - return null; - } - segment = readSegment(elem, 0, false); - - return segment; - } - - private long readNumber(final Element parent) throws IOException { - int length = (int) parent.contentSize; - long value = 0; - while (length-- > 0) { - final int read = stream.read(); - if (read == -1) { - throw new EOFException(); - } - value = (value << 8) | read; - } - return value; - } - - private String readString(final Element parent) throws IOException { - return new String(readBlob(parent), StandardCharsets.UTF_8); // or use "utf-8" - } - - private byte[] readBlob(final Element parent) throws IOException { - final long length = parent.contentSize; - final byte[] buffer = new byte[(int) length]; - final int read = stream.read(buffer); - if (read < length) { - throw new EOFException(); - } - return buffer; - } - - private long readEncodedNumber() throws IOException { - int value = stream.read(); - - if (value > 0) { - byte size = 1; - int mask = 0x80; - - while (size < 9) { - if ((value & mask) == mask) { - mask = 0xFF; - mask >>= size; - - long number = value & mask; - - for (int i = 1; i < size; i++) { - value = stream.read(); - number <<= 8; - number |= value; - } - - return number; - } - - mask >>= 1; - size++; - } - } - - throw new IOException("Invalid encoded length"); - } - - private Element readElement() throws IOException { - final Element elem = new Element(); - elem.offset = stream.position(); - elem.type = (int) readEncodedNumber(); - elem.contentSize = readEncodedNumber(); - elem.size = elem.contentSize + stream.position() - elem.offset; - - return elem; - } - - private Element readElement(final int expected) throws IOException { - final Element elem = readElement(); - if (expected != 0 && elem.type != expected) { - throw new NoSuchElementException("expected " + elementID(expected) - + " found " + elementID(elem.type)); - } - - return elem; - } - - private Element untilElement(final Element ref, final int... expected) throws IOException { - Element elem; - while (ref == null ? stream.available() : (stream.position() < (ref.offset + ref.size))) { - elem = readElement(); - if (expected.length < 1) { - return elem; - } - for (final int type : expected) { - if (elem.type == type) { - return elem; - } - } - - ensure(elem); - } - - return null; - } - - private String elementID(final long type) { - return "0x".concat(Long.toHexString(type)); - } - - private void ensure(final Element ref) throws IOException { - final long skip = (ref.offset + ref.size) - stream.position(); - - if (skip == 0) { - return; - } else if (skip < 0) { - throw new EOFException(String.format( - "parser go beyond limits of the Element. type=%s offset=%s size=%s position=%s", - elementID(ref.type), ref.offset, ref.size, stream.position() - )); - } - - stream.skipBytes(skip); - } - - private boolean readEbml(final Element ref, final int minReadVersion, - final int minDocTypeVersion) throws IOException { - Element elem = untilElement(ref, ID_EMBL_READ_VERSION); - if (elem == null) { - return false; - } - if (readNumber(elem) > minReadVersion) { - return false; - } - - elem = untilElement(ref, ID_EMBL_DOC_TYPE); - if (elem == null) { - return false; - } - if (!readString(elem).equals("webm")) { - return false; - } - elem = untilElement(ref, ID_EMBL_DOC_TYPE_READ_VERSION); - - return elem != null && readNumber(elem) <= minDocTypeVersion; - } - - private Info readInfo(final Element ref) throws IOException { - Element elem; - final Info info = new Info(); - - while ((elem = untilElement(ref, ID_TIMECODE_SCALE, ID_DURATION)) != null) { - switch (elem.type) { - case ID_TIMECODE_SCALE: - info.timecodeScale = readNumber(elem); - break; - case ID_DURATION: - info.duration = readNumber(elem); - break; - } - ensure(elem); - } - - if (info.timecodeScale == 0) { - throw new NoSuchElementException("Element Timecode not found"); - } - - return info; - } - - private Segment readSegment(final Element ref, final int trackLacingExpected, - final boolean metadataExpected) throws IOException { - final Segment obj = new Segment(ref); - Element elem; - while ((elem = untilElement(ref, ID_INFO, ID_TRACKS, ID_CLUSTER)) != null) { - if (elem.type == ID_CLUSTER) { - obj.currentCluster = elem; - break; - } - switch (elem.type) { - case ID_INFO: - obj.info = readInfo(elem); - break; - case ID_TRACKS: - obj.tracks = readTracks(elem, trackLacingExpected); - break; - } - ensure(elem); - } - - if (metadataExpected && (obj.info == null || obj.tracks == null)) { - throw new RuntimeException( - "Cluster element found without Info and/or Tracks element at position " - + ref.offset); - } - - return obj; - } - - private WebMTrack[] readTracks(final Element ref, final int lacingExpected) throws IOException { - final ArrayList trackEntries = new ArrayList<>(2); - Element elemTrackEntry; - - while ((elemTrackEntry = untilElement(ref, ID_TRACK_ENTRY)) != null) { - final WebMTrack entry = new WebMTrack(); - boolean drop = false; - Element elem; - while ((elem = untilElement(elemTrackEntry)) != null) { - switch (elem.type) { - case ID_TRACK_NUMBER: - entry.trackNumber = readNumber(elem); - break; - case ID_TRACK_TYPE: - entry.trackType = (int) readNumber(elem); - break; - case ID_CODEC_ID: - entry.codecId = readString(elem); - break; - case ID_CODEC_PRIVATE: - entry.codecPrivate = readBlob(elem); - break; - case ID_AUDIO: - case ID_VIDEO: - entry.bMetadata = readBlob(elem); - break; - case ID_DEFAULT_DURATION: - entry.defaultDuration = readNumber(elem); - break; - case ID_FLAG_LACING: - drop = readNumber(elem) != lacingExpected; - break; - case ID_CODEC_DELAY: - entry.codecDelay = readNumber(elem); - break; - case ID_SEEK_PRE_ROLL: - entry.seekPreRoll = readNumber(elem); - break; - default: - break; - } - ensure(elem); - } - if (!drop) { - trackEntries.add(entry); - } - ensure(elemTrackEntry); - } - - final WebMTrack[] entries = trackEntries.toArray(new WebMTrack[0]); - - for (final WebMTrack entry : entries) { - switch (entry.trackType) { - case 1: - entry.kind = TrackKind.Video; - break; - case 2: - entry.kind = TrackKind.Audio; - break; - default: - entry.kind = TrackKind.Other; - break; - } - } - - return entries; - } - - private SimpleBlock readSimpleBlock(final Element ref) throws IOException { - final SimpleBlock obj = new SimpleBlock(ref); - obj.trackNumber = readEncodedNumber(); - obj.relativeTimeCode = stream.readShort(); - obj.flags = (byte) stream.read(); - obj.dataSize = (int) ((ref.offset + ref.size) - stream.position()); - obj.createdFromBlock = ref.type == ID_BLOCK; - - // NOTE: lacing is not implemented, and will be mixed with the stream data - if (obj.dataSize < 0) { - throw new IOException(String.format( - "Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)); - } - return obj; - } - - private Cluster readCluster(final Element ref) throws IOException { - final Cluster obj = new Cluster(ref); - - final Element elem = untilElement(ref, ID_TIMECODE); - if (elem == null) { - throw new NoSuchElementException("Cluster at " + ref.offset - + " without Timecode element"); - } - obj.timecode = readNumber(elem); - - return obj; - } - - static class Element { - int type; - long offset; - long contentSize; - long size; - } - - public static class Info { - public long timecodeScale; - public long duration; - } - - public static class WebMTrack { - public long trackNumber; - protected int trackType; - public String codecId; - public byte[] codecPrivate; - public byte[] bMetadata; - public TrackKind kind; - public long defaultDuration = -1; - public long codecDelay = -1; - public long seekPreRoll = -1; - } - - public class Segment { - Segment(final Element ref) { - this.ref = ref; - this.firstClusterInSegment = true; - } - - public Info info; - WebMTrack[] tracks; - private Element currentCluster; - private final Element ref; - boolean firstClusterInSegment; - - public Cluster getNextCluster() throws IOException { - if (done) { - return null; - } - if (firstClusterInSegment && segment.currentCluster != null) { - firstClusterInSegment = false; - return readCluster(segment.currentCluster); - } - ensure(segment.currentCluster); - - final Element elem = untilElement(segment.ref, ID_CLUSTER); - if (elem == null) { - return null; - } - - segment.currentCluster = elem; - - return readCluster(segment.currentCluster); - } - } - - public static class SimpleBlock { - public InputStream data; - public boolean createdFromBlock; - - SimpleBlock(final Element ref) { - this.ref = ref; - } - - public long trackNumber; - public short relativeTimeCode; - public long absoluteTimeCodeNs; - public byte flags; - public int dataSize; - private final Element ref; - - public boolean isKeyframe() { - return (flags & 0x80) == 0x80; - } - } - - public class Cluster { - Element ref; - SimpleBlock currentSimpleBlock = null; - Element currentBlockGroup = null; - public long timecode; - - Cluster(final Element ref) { - this.ref = ref; - } - - boolean insideClusterBounds() { - return stream.position() >= (ref.offset + ref.size); - } - - public SimpleBlock getNextSimpleBlock() throws IOException { - if (insideClusterBounds()) { - return null; - } - - if (currentBlockGroup != null) { - ensure(currentBlockGroup); - currentBlockGroup = null; - currentSimpleBlock = null; - } else if (currentSimpleBlock != null) { - ensure(currentSimpleBlock.ref); - } - - while (!insideClusterBounds()) { - Element elem = untilElement(ref, ID_SIMPLE_BLOCK, ID_GROUP_BLOCK); - if (elem == null) { - return null; - } - - if (elem.type == ID_GROUP_BLOCK) { - currentBlockGroup = elem; - elem = untilElement(currentBlockGroup, ID_BLOCK); - - if (elem == null) { - ensure(currentBlockGroup); - currentBlockGroup = null; - continue; - } - } - - currentSimpleBlock = readSimpleBlock(elem); - if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { - currentSimpleBlock.data = stream.getView(currentSimpleBlock.dataSize); - - // calculate the timestamp in nanoseconds - currentSimpleBlock.absoluteTimeCodeNs = currentSimpleBlock.relativeTimeCode - + this.timecode; - currentSimpleBlock.absoluteTimeCodeNs *= segment.info.timecodeScale; - - return currentSimpleBlock; - } - - ensure(elem); - } - return null; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java deleted file mode 100644 index 530959d96..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ /dev/null @@ -1,761 +0,0 @@ -package org.schabi.newpipe.streams; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.WebMReader.Cluster; -import org.schabi.newpipe.streams.WebMReader.Segment; -import org.schabi.newpipe.streams.WebMReader.SimpleBlock; -import org.schabi.newpipe.streams.WebMReader.WebMTrack; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.Closeable; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; - -/** - * @author kapodamy - */ -public class WebMWriter implements Closeable { - private static final int BUFFER_SIZE = 8 * 1024; - private static final int DEFAULT_TIMECODE_SCALE = 1000000; - private static final int INTERV = 100; // 100ms on 1000000us timecode scale - private static final int DEFAULT_CUES_EACH_MS = 5000; // 5000ms on 1000000us timecode scale - private static final byte CLUSTER_HEADER_SIZE = 8; - private static final int CUE_RESERVE_SIZE = 65535; - private static final byte MINIMUM_EBML_VOID_SIZE = 4; - - private WebMReader.WebMTrack[] infoTracks; - private SharpStream[] sourceTracks; - - private WebMReader[] readers; - - private boolean done = false; - private boolean parsed = false; - - private long written = 0; - - private Segment[] readersSegment; - private Cluster[] readersCluster; - - private ArrayList clustersOffsetsSizes; - - private byte[] outBuffer; - private ByteBuffer outByteBuffer; - - public WebMWriter(final SharpStream... source) { - sourceTracks = source; - readers = new WebMReader[sourceTracks.length]; - infoTracks = new WebMTrack[sourceTracks.length]; - outBuffer = new byte[BUFFER_SIZE]; - outByteBuffer = ByteBuffer.wrap(outBuffer); - clustersOffsetsSizes = new ArrayList<>(256); - } - - public WebMTrack[] getTracksFromSource(final int sourceIndex) throws IllegalStateException { - if (done) { - throw new IllegalStateException("already done"); - } - if (!parsed) { - throw new IllegalStateException("All sources must be parsed first"); - } - - return readers[sourceIndex].getAvailableTracks(); - } - - public void parseSources() throws IOException, IllegalStateException { - if (done) { - throw new IllegalStateException("already done"); - } - if (parsed) { - throw new IllegalStateException("already parsed"); - } - - try { - for (int i = 0; i < readers.length; i++) { - readers[i] = new WebMReader(sourceTracks[i]); - readers[i].parse(); - } - - } finally { - parsed = true; - } - } - - public void selectTracks(final int... trackIndex) throws IOException { - try { - readersSegment = new Segment[readers.length]; - readersCluster = new Cluster[readers.length]; - - for (int i = 0; i < readers.length; i++) { - infoTracks[i] = readers[i].selectTrack(trackIndex[i]); - readersSegment[i] = readers[i].getNextSegment(); - } - } finally { - parsed = true; - } - } - - public boolean isDone() { - return done; - } - - @Override - public void close() { - done = true; - parsed = true; - - for (final SharpStream src : sourceTracks) { - src.close(); - } - - sourceTracks = null; - readers = null; - infoTracks = null; - readersSegment = null; - readersCluster = null; - outBuffer = null; - outByteBuffer = null; - clustersOffsetsSizes = null; - } - - @SuppressWarnings("MethodLength") - public void build(final SharpStream out) throws IOException, RuntimeException { - if (!out.canRewind()) { - throw new IOException("The output stream must be allow seek"); - } - - makeEBML(out); - - final long offsetSegmentSizeSet = written + 5; - final long offsetInfoDurationSet = written + 94; - final long offsetClusterSet = written + 58; - final long offsetCuesSet = written + 75; - - final ArrayList listBuffer = new ArrayList<>(4); - - /* segment */ - listBuffer.add(new byte[]{ - 0x18, 0x53, (byte) 0x80, 0x67, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size - }); - - final long segmentOffset = written + listBuffer.get(0).length; - - /* seek head */ - listBuffer.add(new byte[]{ - 0x11, 0x4d, (byte) 0x9b, 0x74, (byte) 0xbe, - 0x4d, (byte) 0xbb, (byte) 0x8b, - 0x53, (byte) 0xab, (byte) 0x84, 0x15, 0x49, (byte) 0xa9, 0x66, 0x53, - (byte) 0xac, (byte) 0x81, - /*info offset*/ 0x43, - 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, - (byte) 0x84, 0x16, 0x54, (byte) 0xae, 0x6b, 0x53, (byte) 0xac, (byte) 0x81, - /*tracks offset*/ 0x56, - 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1f, - 0x43, (byte) 0xb6, 0x75, 0x53, (byte) 0xac, (byte) 0x84, - /*cluster offset [2]*/ 0x00, 0x00, 0x00, 0x00, - 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1c, 0x53, - (byte) 0xbb, 0x6b, 0x53, (byte) 0xac, (byte) 0x84, - /*cues offset [7]*/ 0x00, 0x00, 0x00, 0x00 - }); - - /* info */ - listBuffer.add(new byte[]{ - 0x15, 0x49, (byte) 0xa9, 0x66, (byte) 0x8e, 0x2a, (byte) 0xd7, (byte) 0xb1 - }); - // the segment duration MUST NOT exceed 4 bytes - listBuffer.add(encode(DEFAULT_TIMECODE_SCALE, true)); - listBuffer.add(new byte[]{0x44, (byte) 0x89, (byte) 0x84, - 0x00, 0x00, 0x00, 0x00, // info.duration - }); - - /* tracks */ - listBuffer.addAll(makeTracks()); - - dump(listBuffer, out); - - // reserve space for Cues element - final long cueOffset = written; - makeEbmlVoid(out, CUE_RESERVE_SIZE, true); - - final int[] defaultSampleDuration = new int[infoTracks.length]; - final long[] duration = new long[infoTracks.length]; - - for (int i = 0; i < infoTracks.length; i++) { - if (infoTracks[i].defaultDuration < 0) { - defaultSampleDuration[i] = -1; // not available - } else { - defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration - / (float) DEFAULT_TIMECODE_SCALE); - } - duration[i] = -1; - } - - // Select a track for the cue - final int cuesForTrackId = selectTrackForCue(); - long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; - final ArrayList keyFrames = new ArrayList<>(32); - - int firstClusterOffset = (int) written; - long currentClusterOffset = makeCluster(out, 0, 0, true); - - long baseTimecode = 0; - long limitTimecode = -1; - int limitTimecodeByTrackId = cuesForTrackId; - - int blockWritten = Integer.MAX_VALUE; - - int newClusterByTrackId = -1; - - while (blockWritten > 0) { - blockWritten = 0; - int i = 0; - while (i < readers.length) { - final Block bloq = getNextBlockFrom(i); - if (bloq == null) { - i++; - continue; - } - - if (bloq.data == null) { - blockWritten = 1; // fake block - newClusterByTrackId = i; - i++; - continue; - } - - if (newClusterByTrackId == i) { - limitTimecodeByTrackId = i; - newClusterByTrackId = -1; - baseTimecode = bloq.absoluteTimecode; - limitTimecode = baseTimecode + INTERV; - currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, - true); - } - - if (cuesForTrackId == i) { - if ((nextCueTime > -1 && bloq.absoluteTimecode >= nextCueTime) - || (nextCueTime < 0 && bloq.isKeyframe())) { - if (nextCueTime > -1) { - nextCueTime += DEFAULT_CUES_EACH_MS; - } - keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, - bloq.absoluteTimecode)); - } - } - - writeBlock(out, bloq, baseTimecode); - blockWritten++; - - if (defaultSampleDuration[i] < 0 && duration[i] >= 0) { - // if the sample duration in unknown, - // calculate using current_duration - previous_duration - defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]); - } - duration[i] = bloq.absoluteTimecode; - - if (limitTimecode < 0) { - limitTimecode = bloq.absoluteTimecode + INTERV; - continue; - } - - if (bloq.absoluteTimecode >= limitTimecode) { - if (limitTimecodeByTrackId != i) { - limitTimecode += INTERV - (bloq.absoluteTimecode - limitTimecode); - } - i++; - } - } - } - - makeCluster(out, -1, currentClusterOffset, false); - - final long segmentSize = written - offsetSegmentSizeSet - 7; - - /* Segment size */ - seekTo(out, offsetSegmentSizeSet); - outByteBuffer.putLong(0, segmentSize); - out.write(outBuffer, 1, DataReader.LONG_SIZE - 1); - - /* Segment duration */ - long longestDuration = 0; - for (int i = 0; i < duration.length; i++) { - if (defaultSampleDuration[i] > 0) { - duration[i] += defaultSampleDuration[i]; - } - if (duration[i] > longestDuration) { - longestDuration = duration[i]; - } - } - seekTo(out, offsetInfoDurationSet); - outByteBuffer.putFloat(0, longestDuration); - dump(outBuffer, DataReader.FLOAT_SIZE, out); - - /* first Cluster offset */ - firstClusterOffset -= segmentOffset; - writeInt(out, offsetClusterSet, firstClusterOffset); - - seekTo(out, cueOffset); - - /* Cue */ - short cueSize = 0; - dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); // header size is 7 - - for (final KeyFrame keyFrame : keyFrames) { - final int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer); - - if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) { - break; // no space left - } - - cueSize += size; - dump(outBuffer, size, out); - } - - makeEbmlVoid(out, CUE_RESERVE_SIZE - cueSize - 7, false); - - seekTo(out, cueOffset + 5); - outByteBuffer.putShort(0, cueSize); - dump(outBuffer, DataReader.SHORT_SIZE, out); - - /* seek head, seek for cues element */ - writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset)); - - for (final ClusterInfo cluster : clustersOffsetsSizes) { - writeInt(out, cluster.offset, cluster.size | 0x10000000); - } - } - - private Block getNextBlockFrom(final int internalTrackId) throws IOException { - if (readersSegment[internalTrackId] == null) { - readersSegment[internalTrackId] = readers[internalTrackId].getNextSegment(); - if (readersSegment[internalTrackId] == null) { - return null; // no more blocks in the selected track - } - } - - if (readersCluster[internalTrackId] == null) { - readersCluster[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); - if (readersCluster[internalTrackId] == null) { - readersSegment[internalTrackId] = null; - return getNextBlockFrom(internalTrackId); - } - } - - final SimpleBlock res = readersCluster[internalTrackId].getNextSimpleBlock(); - if (res == null) { - readersCluster[internalTrackId] = null; - return new Block(); // fake block to indicate the end of the cluster - } - - final Block bloq = new Block(); - bloq.data = res.data; - bloq.dataSize = res.dataSize; - bloq.trackNumber = internalTrackId; - bloq.flags = res.flags; - bloq.absoluteTimecode = res.absoluteTimeCodeNs / DEFAULT_TIMECODE_SCALE; - - return bloq; - } - - private void seekTo(final SharpStream stream, final long offset) throws IOException { - if (stream.canSeek()) { - stream.seek(offset); - } else { - if (offset > written) { - stream.skip(offset - written); - } else { - stream.rewind(); - stream.skip(offset); - } - } - - written = offset; - } - - private void writeInt(final SharpStream stream, final long offset, final int number) - throws IOException { - seekTo(stream, offset); - outByteBuffer.putInt(0, number); - dump(outBuffer, DataReader.INTEGER_SIZE, stream); - } - - private void writeBlock(final SharpStream stream, final Block bloq, final long clusterTimecode) - throws IOException { - final long relativeTimeCode = bloq.absoluteTimecode - clusterTimecode; - - if (relativeTimeCode < Short.MIN_VALUE || relativeTimeCode > Short.MAX_VALUE) { - throw new IndexOutOfBoundsException("SimpleBlock timecode overflow."); - } - - final ArrayList listBuffer = new ArrayList<>(5); - listBuffer.add(new byte[]{(byte) 0xa3}); - listBuffer.add(null); // block size - listBuffer.add(encode(bloq.trackNumber + 1, false)); - listBuffer.add(ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort((short) relativeTimeCode) - .array()); - listBuffer.add(new byte[]{bloq.flags}); - - int blockSize = bloq.dataSize; - for (int i = 2; i < listBuffer.size(); i++) { - blockSize += listBuffer.get(i).length; - } - listBuffer.set(1, encode(blockSize, false)); - - dump(listBuffer, stream); - - int read; - while ((read = bloq.data.read(outBuffer)) > 0) { - dump(outBuffer, read, stream); - } - } - - private long makeCluster(final SharpStream stream, final long timecode, final long offsetStart, - final boolean create) throws IOException { - ClusterInfo cluster; - long offset = offsetStart; - - if (offset > 0) { - // save the size of the previous cluster (maximum 256 MiB) - cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1); - cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE); - } - - offset = written; - - if (create) { - /* cluster */ - dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); - - cluster = new ClusterInfo(); - cluster.offset = written; - clustersOffsetsSizes.add(cluster); - - dump(new byte[]{ - 0x10, 0x00, 0x00, 0x00, - /* timestamp */ - (byte) 0xe7 - }, stream); - - dump(encode(timecode, true), stream); - } - - return offset; - } - - private void makeEBML(final SharpStream stream) throws IOException { - // default values - dump(new byte[]{ - 0x1A, 0x45, (byte) 0xDF, (byte) 0xA3, 0x01, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x1F, 0x42, (byte) 0x86, (byte) 0x81, 0x01, - 0x42, (byte) 0xF7, (byte) 0x81, 0x01, 0x42, (byte) 0xF2, (byte) 0x81, 0x04, - 0x42, (byte) 0xF3, (byte) 0x81, 0x08, 0x42, (byte) 0x82, (byte) 0x84, 0x77, - 0x65, 0x62, 0x6D, 0x42, (byte) 0x87, (byte) 0x81, 0x02, - 0x42, (byte) 0x85, (byte) 0x81, 0x02 - }, stream); - } - - private ArrayList makeTracks() { - final ArrayList buffer = new ArrayList<>(1); - buffer.add(new byte[]{0x16, 0x54, (byte) 0xae, 0x6b}); - buffer.add(null); - - for (int i = 0; i < infoTracks.length; i++) { - buffer.addAll(makeTrackEntry(i, infoTracks[i])); - } - - return lengthFor(buffer); - } - - private ArrayList makeTrackEntry(final int internalTrackId, final WebMTrack track) { - final byte[] id = encode(internalTrackId + 1, true); - final ArrayList buffer = new ArrayList<>(12); - - /* track */ - buffer.add(new byte[]{(byte) 0xae}); - buffer.add(null); - - /* track number */ - buffer.add(new byte[]{(byte) 0xd7}); - buffer.add(id); - - /* track uid */ - buffer.add(new byte[]{0x73, (byte) 0xc5}); - buffer.add(id); - - /* flag lacing */ - buffer.add(new byte[]{(byte) 0x9c, (byte) 0x81, 0x00}); - - /* lang */ - buffer.add(new byte[]{0x22, (byte) 0xb5, (byte) 0x9c, (byte) 0x83, 0x75, 0x6e, 0x64}); - - /* codec id */ - buffer.add(new byte[]{(byte) 0x86}); - buffer.addAll(encode(track.codecId)); - - /* codec delay*/ - if (track.codecDelay >= 0) { - buffer.add(new byte[]{0x56, (byte) 0xAA}); - buffer.add(encode(track.codecDelay, true)); - } - - /* codec seek pre-roll*/ - if (track.seekPreRoll >= 0) { - buffer.add(new byte[]{0x56, (byte) 0xBB}); - buffer.add(encode(track.seekPreRoll, true)); - } - - /* type */ - buffer.add(new byte[]{(byte) 0x83}); - buffer.add(encode(track.trackType, true)); - - /* default duration */ - if (track.defaultDuration >= 0) { - buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83}); - buffer.add(encode(track.defaultDuration, true)); - } - - /* audio/video */ - if ((track.trackType == 1 || track.trackType == 2) && valid(track.bMetadata)) { - buffer.add(new byte[]{(byte) (track.trackType == 1 ? 0xe0 : 0xe1)}); - buffer.add(encode(track.bMetadata.length, false)); - buffer.add(track.bMetadata); - } - - /* codec private*/ - if (valid(track.codecPrivate)) { - buffer.add(new byte[]{0x63, (byte) 0xa2}); - buffer.add(encode(track.codecPrivate.length, false)); - buffer.add(track.codecPrivate); - } - - return lengthFor(buffer); - } - - private int makeCuePoint(final int internalTrackId, final KeyFrame keyFrame, - final byte[] buffer) { - final ArrayList cue = new ArrayList<>(5); - - /* CuePoint */ - cue.add(new byte[]{(byte) 0xbb}); - cue.add(null); - - /* CueTime */ - cue.add(new byte[]{(byte) 0xb3}); - cue.add(encode(keyFrame.duration, true)); - - /* CueTrackPosition */ - cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); - - int size = 0; - lengthFor(cue); - - for (final byte[] buff : cue) { - System.arraycopy(buff, 0, buffer, size, buff.length); - size += buff.length; - } - - return size; - } - - private ArrayList makeCueTrackPosition(final int internalTrackId, - final KeyFrame keyFrame) { - final ArrayList buffer = new ArrayList<>(8); - - /* CueTrackPositions */ - buffer.add(new byte[]{(byte) 0xb7}); - buffer.add(null); - - /* CueTrack */ - buffer.add(new byte[]{(byte) 0xf7}); - buffer.add(encode(internalTrackId + 1, true)); - - /* CueClusterPosition */ - buffer.add(new byte[]{(byte) 0xf1}); - buffer.add(encode(keyFrame.clusterPosition, true)); - - /* CueRelativePosition */ - if (keyFrame.relativePosition > 0) { - buffer.add(new byte[]{(byte) 0xf0}); - buffer.add(encode(keyFrame.relativePosition, true)); - } - - return lengthFor(buffer); - } - - private void makeEbmlVoid(final SharpStream out, final int amount, final boolean wipe) - throws IOException { - int size = amount; - - /* ebml void */ - outByteBuffer.putShort(0, (short) 0xec20); - outByteBuffer.putShort(2, (short) (size - 4)); - - dump(outBuffer, 4, out); - - if (wipe) { - size -= 4; - while (size > 0) { - final int write = Math.min(size, outBuffer.length); - dump(outBuffer, write, out); - size -= write; - } - } - } - - private void dump(final byte[] buffer, final SharpStream stream) throws IOException { - dump(buffer, buffer.length, stream); - } - - private void dump(final byte[] buffer, final int count, final SharpStream stream) - throws IOException { - stream.write(buffer, 0, count); - written += count; - } - - private void dump(final ArrayList buffers, final SharpStream stream) - throws IOException { - for (final byte[] buffer : buffers) { - stream.write(buffer); - written += buffer.length; - } - } - - private ArrayList lengthFor(final ArrayList buffer) { - long size = 0; - for (int i = 2; i < buffer.size(); i++) { - size += buffer.get(i).length; - } - buffer.set(1, encode(size, false)); - return buffer; - } - - private byte[] encode(final long number, final boolean withLength) { - int length = -1; - for (int i = 1; i <= 7; i++) { - if (number < Math.pow(2, 7 * i)) { - length = i; - break; - } - } - - if (length < 1) { - throw new ArithmeticException("Can't encode a number of bigger than 7 bytes"); - } - - if (number == (Math.pow(2, 7 * length)) - 1) { - length++; - } - - final int offset = withLength ? 1 : 0; - final byte[] buffer = new byte[offset + length]; - final long marker = Math.floorDiv(length - 1, 8); - - int shift = 0; - for (int i = length - 1; i >= 0; i--, shift += 8) { - long b = number >>> shift; - if (!withLength && i == marker) { - b = b | (0x80 >>> (length - 1)); - } - buffer[offset + i] = (byte) b; - } - - if (withLength) { - buffer[0] = (byte) (0x80 | length); - } - - return buffer; - } - - private ArrayList encode(final String value) { - final byte[] str = value.getBytes(StandardCharsets.UTF_8); // or use "utf-8" - - final ArrayList buffer = new ArrayList<>(2); - buffer.add(encode(str.length, false)); - buffer.add(str); - - return buffer; - } - - private boolean valid(final byte[] buffer) { - return buffer != null && buffer.length > 0; - } - - private int selectTrackForCue() { - int i = 0; - int videoTracks = 0; - int audioTracks = 0; - - for (; i < infoTracks.length; i++) { - switch (infoTracks[i].trackType) { - case 1: - videoTracks++; - break; - case 2: - audioTracks++; - break; - } - } - - final int kind; - if (audioTracks == infoTracks.length) { - kind = 2; - } else if (videoTracks == infoTracks.length) { - kind = 1; - } else if (videoTracks > 0) { - kind = 1; - } else if (audioTracks > 0) { - kind = 2; - } else { - return 0; - } - - // TODO: in the above code, find and select the shortest track for the desired kind - for (i = 0; i < infoTracks.length; i++) { - if (kind == infoTracks[i].trackType) { - return i; - } - } - - return 0; - } - - static class KeyFrame { - KeyFrame(final long segment, final long cluster, final long block, final long timecode) { - clusterPosition = cluster - segment; - relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE); - duration = timecode; - } - - final long clusterPosition; - final int relativePosition; - final long duration; - } - - static class Block { - InputStream data; - int trackNumber; - byte flags; - int dataSize; - long absoluteTimecode; - - boolean isKeyframe() { - return (flags & 0x80) == 0x80; - } - - @NonNull - @Override - public String toString() { - return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, - isKeyframe(), absoluteTimecode); - } - } - - static class ClusterInfo { - long offset; - int size; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java b/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java deleted file mode 100644 index df43c34c1..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java +++ /dev/null @@ -1,75 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.os.Build; -import android.util.Log; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.appcompat.app.AlertDialog; - -import org.schabi.newpipe.R; - -/** - * Helper for when no file-manager/activity was found. - */ -public final class NoFileManagerSafeGuard { - private NoFileManagerSafeGuard() { - // No impl - } - - /** - * Shows an alert dialog when no file-manager is found. - * @param context Context - */ - private static void showActivityNotFoundAlert(final Context context) { - if (context == null) { - throw new IllegalArgumentException( - "Unable to open no file manager alert dialog: Context is null"); - } - - final String message; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Android 10+ only allows SAF - message = context.getString(R.string.no_appropriate_file_manager_message_android_10); - } else { - message = context.getString( - R.string.no_appropriate_file_manager_message, - context.getString(R.string.downloads_storage_use_saf_title)); - } - - - new AlertDialog.Builder(context) - .setTitle(R.string.no_app_to_open_intent) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .show(); - } - - /** - * Launches the file manager safely. - * - * If no file manager is found (which is normally only the case when the user uninstalled - * the default file manager or the OS lacks one) an alert dialog shows up, asking the user - * to fix the situation. - * - * @param activityResultLauncher see {@link ActivityResultLauncher#launch(Object)} - * @param input see {@link ActivityResultLauncher#launch(Object)} - * @param tag Tag used for logging - * @param context Context - * @param see {@link ActivityResultLauncher#launch(Object)} - */ - public static void launchSafe( - final ActivityResultLauncher activityResultLauncher, - final I input, - final String tag, - final Context context - ) { - try { - activityResultLauncher.launch(input); - } catch (final ActivityNotFoundException aex) { - Log.w(tag, "Unable to launch file/directory picker", aex); - NoFileManagerSafeGuard.showActivityNotFoundAlert(context); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java deleted file mode 100644 index 956e9865c..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import androidx.annotation.NonNull; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Simply wraps a readable {@link SharpStream} allowing it to be used with built-in Java stuff that - * supports {@link InputStream}. - */ -public class SharpInputStream extends InputStream { - private final SharpStream stream; - - public SharpInputStream(final SharpStream stream) throws IOException { - if (!stream.canRead()) { - throw new IOException("SharpStream is not readable"); - } - this.stream = stream; - } - - @Override - public int read() throws IOException { - return stream.read(); - } - - @Override - public int read(@NonNull final byte[] b) throws IOException { - return stream.read(b); - } - - @Override - public int read(@NonNull final byte[] b, final int off, final int len) throws IOException { - return stream.read(b, off, len); - } - - @Override - public long skip(final long n) throws IOException { - return stream.skip(n); - } - - @Override - public int available() { - final long res = stream.available(); - return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; - } - - @Override - public void close() { - stream.close(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java deleted file mode 100644 index 76e394312..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import androidx.annotation.NonNull; - -import java.io.IOException; -import java.io.OutputStream; - -/** - * Simply wraps a writable {@link SharpStream} allowing it to be used with built-in Java stuff that - * supports {@link OutputStream}. - */ -public class SharpOutputStream extends OutputStream { - private final SharpStream stream; - - public SharpOutputStream(final SharpStream stream) throws IOException { - if (!stream.canWrite()) { - throw new IOException("SharpStream is not writable"); - } - this.stream = stream; - } - - @Override - public void write(final int b) throws IOException { - stream.write((byte) b); - } - - @Override - public void write(@NonNull final byte[] b) throws IOException { - stream.write(b); - } - - @Override - public void write(@NonNull final byte[] b, final int off, final int len) throws IOException { - stream.write(b, off, len); - } - - @Override - public void flush() throws IOException { - stream.flush(); - } - - @Override - public void close() { - stream.close(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java deleted file mode 100644 index 849c7c051..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import java.io.Closeable; -import java.io.Flushable; -import java.io.IOException; - -/** - * Based on C#'s Stream class. SharpStream is a wrapper around the 2 different APIs for SAF - * ({@link us.shandian.giga.io.FileStreamSAF}) and non-SAF ({@link us.shandian.giga.io.FileStream}). - * It has both input and output like in C#, while in Java those are usually different classes. - * {@link SharpInputStream} and {@link SharpOutputStream} are simple classes that wrap - * {@link SharpStream} and extend respectively {@link java.io.InputStream} and - * {@link java.io.OutputStream}, since unfortunately a class can only extend one class, so that a - * sharp stream can be used with built-in Java stuff that supports {@link java.io.InputStream} - * or {@link java.io.OutputStream}. - */ -public abstract class SharpStream implements Closeable, Flushable { - public abstract int read() throws IOException; - - public abstract int read(byte[] buffer) throws IOException; - - public abstract int read(byte[] buffer, int offset, int count) throws IOException; - - public abstract long skip(long amount) throws IOException; - - public abstract long available(); - - public abstract void rewind() throws IOException; - - public abstract boolean isClosed(); - - @Override - public abstract void close(); - - public abstract boolean canRewind(); - - public abstract boolean canRead(); - - public abstract boolean canWrite(); - - public boolean canSetLength() { - return false; - } - - public boolean canSeek() { - return false; - } - - public abstract void write(byte value) throws IOException; - - public abstract void write(byte[] buffer) throws IOException; - - public abstract void write(byte[] buffer, int offset, int count) throws IOException; - - public void flush() throws IOException { - // STUB - } - - public void setLength(final long length) throws IOException { - throw new IOException("Not implemented"); - } - - public void seek(final long offset) throws IOException { - throw new IOException("Not implemented"); - } - - public long length() throws IOException { - throw new UnsupportedOperationException("Unsupported operation"); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java deleted file mode 100644 index bb47a4b91..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java +++ /dev/null @@ -1,402 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; -import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.DocumentsContract; -import android.system.Os; -import android.system.StructStatVfs; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; - -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.util.FilePickerActivityHelper; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class StoredDirectoryHelper { - private static final String TAG = StoredDirectoryHelper.class.getSimpleName(); - public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - - private Path ioTree; - private DocumentFile docTree; - - /** - * Context is `null` for non-SAF files, i.e. files that use `ioTree`. - */ - @Nullable - private Context context; - - private final String tag; - - public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path, - final String tag) throws IOException { - this.tag = tag; - - if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { - ioTree = Paths.get(URI.create(path.toString())); - return; - } - - this.context = context; - - try { - this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); - } catch (final Exception e) { - throw new IOException(e); - } - - this.docTree = DocumentFile.fromTreeUri(context, path); - - if (this.docTree == null) { - throw new IOException("Failed to create the tree from Uri"); - } - } - - public StoredFileHelper createFile(final String filename, final String mime) { - return createFile(filename, mime, false); - } - - public StoredFileHelper createUniqueFile(final String name, final String mime) { - final List matches = new ArrayList<>(); - final String[] filename = splitFilename(name); - final String lcFileName = filename[0].toLowerCase(); - - if (docTree == null) { - try (Stream stream = Files.list(ioTree)) { - matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase()) - .filter(fileName -> fileName.startsWith(lcFileName)) - .collect(Collectors.toList())); - } catch (final IOException e) { - Log.e(TAG, "Exception while traversing " + ioTree, e); - } - } else { - // warning: SAF file listing is very slow - final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( - docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())); - - final String[] projection = new String[]{COLUMN_DISPLAY_NAME}; - final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; - final ContentResolver cr = context.getContentResolver(); - - try (Cursor cursor = cr.query(docTreeChildren, projection, selection, - new String[]{lcFileName}, null)) { - if (cursor != null) { - while (cursor.moveToNext()) { - addIfStartWith(matches, lcFileName, cursor.getString(0)); - } - } - } - } - - if (matches.isEmpty()) { - return createFile(name, mime, true); - } - - // check if the filename is in use - String lcName = name.toLowerCase(); - for (final String testName : matches) { - if (testName.equals(lcName)) { - lcName = null; - break; - } - } - - // create file if filename not in use - if (lcName != null) { - return createFile(name, mime, true); - } - - Collections.sort(matches, String::compareTo); - - for (int i = 1; i < 1000; i++) { - if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename[1])) < 0) { - return createFile(makeFileName(filename[0], i, filename[1]), mime, true); - } - } - - return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, - false); - } - - private StoredFileHelper createFile(final String filename, final String mime, - final boolean safe) { - final StoredFileHelper storage; - - try { - if (docTree == null) { - storage = new StoredFileHelper(ioTree, filename, mime); - } else { - storage = new StoredFileHelper(context, docTree, filename, mime, safe); - } - } catch (final IOException e) { - return null; - } - - storage.tag = tag; - - return storage; - } - - public Uri getUri() { - return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri(); - } - - public boolean exists() { - return docTree == null ? Files.exists(ioTree) : docTree.exists(); - } - - /** - * Indicates whether it's using the {@code java.io} API. - * - * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework - */ - public boolean isDirect() { - return docTree == null; - } - - /** - * Get free memory of the storage partition this file belongs to (root of the directory). - * See StackOverflow and - * - * {@code statvfs()} and {@code fstatvfs()} docs - * - * @return amount of free memory in the volume of current directory (bytes), or {@link - * Long#MAX_VALUE} if an error occurred - */ - public long getFreeStorageSpace() { - try { - final StructStatVfs stat; - - if (ioTree != null) { - // non-SAF file, use statvfs with the path directly (also, `context` would be null - // for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway) - stat = Os.statvfs(ioTree.toString()); - - } else { - // SAF file, we can't get a path directly, so obtain a file descriptor first - // and then use fstatvfs with the file descriptor - try (ParcelFileDescriptor parcelFileDescriptor = - context.getContentResolver().openFileDescriptor(getUri(), "r")) { - if (parcelFileDescriptor == null) { - return Long.MAX_VALUE; - } - final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); - stat = Os.fstatvfs(fileDescriptor); - } - } - - // this is the same formula used inside the FsStat class - return stat.f_bavail * stat.f_frsize; - } catch (final Throwable e) { - // ignore any error - Log.e(TAG, "Could not get free storage space", e); - return Long.MAX_VALUE; - } - } - - /** - * Only using Java I/O. Creates the directory named by this abstract pathname, including any - * necessary but nonexistent parent directories. - * Note that if this operation fails it may have succeeded in creating some of the necessary - * parent directories. - * - * @return true if and only if the directory was created, - * along with all necessary parent directories or already exists; false - * otherwise - */ - public boolean mkdirs() { - if (docTree == null) { - try { - Files.createDirectories(ioTree); - } catch (final IOException e) { - Log.e(TAG, "Error while creating directories at " + ioTree, e); - } - return Files.exists(ioTree); - } - - if (docTree.exists()) { - return true; - } - - try { - DocumentFile parent; - String child = docTree.getName(); - - while (true) { - parent = docTree.getParentFile(); - if (parent == null || child == null) { - break; - } - if (parent.exists()) { - return true; - } - - parent.createDirectory(child); - - child = parent.getName(); // for the next iteration - } - } catch (final Exception ignored) { - // no more parent directories or unsupported by the storage provider - } - - return false; - } - - public String getTag() { - return tag; - } - - public Uri findFile(final String filename) { - if (docTree == null) { - final Path res = ioTree.resolve(filename); - return Files.exists(res) ? Uri.fromFile(res.toFile()) : null; - } - - final DocumentFile res = findFileSAFHelper(context, docTree, filename); - return res == null ? null : res.getUri(); - } - - public boolean canWrite() { - return docTree == null ? Files.isWritable(ioTree) : docTree.canWrite(); - } - - /** - * @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if - * SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings -> - * Apps & notifications -> NewPipe -> Storage & cache -> Clear access}); - */ - public boolean isInvalidSafStorage() { - return docTree != null && docTree.getName() == null; - } - - @NonNull - @Override - public String toString() { - return (docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri()).toString(); - } - - //////////////////// - // Utils - /////////////////// - - private static void addIfStartWith(final List list, @NonNull final String base, - final String str) { - if (isNullOrEmpty(str)) { - return; - } - final String lowerStr = str.toLowerCase(); - if (lowerStr.startsWith(base)) { - list.add(lowerStr); - } - } - - /** - * Splits the filename into the name and extension. - * - * @param filename The filename to split - * @return A String array with the name at index 0 and extension at index 1 - */ - private static String[] splitFilename(@NonNull final String filename) { - final int dotIndex = filename.lastIndexOf('.'); - - if (dotIndex < 0 || (dotIndex == filename.length() - 1)) { - return new String[]{filename, ""}; - } - - return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; - } - - private static String makeFileName(final String name, final int idx, final String ext) { - return name + "(" + idx + ")" + ext; - } - - /** - * Fast (but not enough) file/directory finder under the storage access framework. - * - * @param context The context - * @param tree Directory where search - * @param filename Target filename - * @return A {@link DocumentFile} contain the reference, otherwise, null - */ - static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree, - final String filename) { - if (context == null) { - return tree.findFile(filename); // warning: this is very slow - } - - if (!tree.canRead()) { - return null; // missing read permission - } - - final int name = 0; - final int documentId = 1; - - // LOWER() SQL function is not supported - final String selection = COLUMN_DISPLAY_NAME + " = ?"; - //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; - - final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(), - DocumentsContract.getDocumentId(tree.getUri())); - final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; - final ContentResolver contentResolver = context.getContentResolver(); - - final String lowerFilename = filename.toLowerCase(); - - try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, - new String[]{lowerFilename}, null)) { - if (cursor == null) { - return null; - } - - while (cursor.moveToNext()) { - if (cursor.isNull(name) - || !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) { - continue; - } - - return DocumentFile.fromSingleUri(context, - DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), - cursor.getString(documentId))); - } - } - - return null; - } - - public static Intent getPicker(final Context ctx) { - if (NewPipeSettings.useStorageAccessFramework(ctx)) { - return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - .putExtra("android.content.extra.SHOW_ADVANCED", true) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | StoredDirectoryHelper.PERMISSION_FLAGS); - } else { - return new Intent(ctx, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_DIR); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java deleted file mode 100644 index 1e2c178bf..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java +++ /dev/null @@ -1,590 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; - -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.util.FilePickerActivityHelper; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -import us.shandian.giga.io.FileStream; -import us.shandian.giga.io.FileStreamSAF; - -public class StoredFileHelper implements Serializable { - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = StoredFileHelper.class.getSimpleName(); - - private static final long serialVersionUID = 0L; - public static final String DEFAULT_MIME = "application/octet-stream"; - - private transient DocumentFile docFile; - private transient DocumentFile docTree; - private transient Path ioPath; - private transient Context context; - - protected String source; - private String sourceTree; - - protected String tag; - - private String srcName; - private String srcType; - - public StoredFileHelper(final Context context, final Uri uri, final String mime) { - if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { - final File ioFile = Utils.getFileForUri(uri); - ioPath = ioFile.toPath(); - source = Uri.fromFile(ioFile).toString(); - } else { - docFile = DocumentFile.fromSingleUri(context, uri); - source = uri.toString(); - } - - this.context = context; - this.srcType = mime; - } - - public StoredFileHelper(@Nullable final Uri parent, final String filename, final String mime, - final String tag) { - this.source = null; // this instance will be "invalid" see invalidate()/isInvalid() methods - - this.srcName = filename; - this.srcType = mime == null ? DEFAULT_MIME : mime; - if (parent != null) { - this.sourceTree = parent.toString(); - } - - this.tag = tag; - } - - StoredFileHelper(@Nullable final Context context, final DocumentFile tree, - final String filename, final String mime, final boolean safe) - throws IOException { - this.docTree = tree; - this.context = context; - - final DocumentFile res; - - if (safe) { - // no conflicts (the filename is not in use) - res = this.docTree.createFile(mime, filename); - if (res == null) { - throw new IOException("Cannot create the file"); - } - } else { - res = createSAF(context, mime, filename); - } - - this.docFile = res; - - this.source = docFile.getUri().toString(); - this.sourceTree = docTree.getUri().toString(); - - this.srcName = this.docFile.getName(); - this.srcType = this.docFile.getType(); - } - - StoredFileHelper(final Path location, final String filename, final String mime) - throws IOException { - ioPath = location.resolve(filename); - - Files.deleteIfExists(ioPath); - Files.createFile(ioPath); - - source = Uri.fromFile(ioPath.toFile()).toString(); - sourceTree = Uri.fromFile(location.toFile()).toString(); - - srcName = ioPath.getFileName().toString(); - srcType = mime; - } - - public StoredFileHelper(final Context context, @Nullable final Uri parent, - @NonNull final Uri path, final String tag) throws IOException { - this.tag = tag; - this.source = path.toString(); - - if (path.getScheme() == null - || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { - this.ioPath = Paths.get(URI.create(this.source)); - } else { - final DocumentFile file = DocumentFile.fromSingleUri(context, path); - - if (file == null) { - throw new IOException("SAF not available"); - } - - this.context = context; - - if (file.getName() == null) { - this.source = null; - return; - } else { - this.docFile = file; - takePermissionSAF(); - } - } - - if (parent != null) { - if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) { - this.docTree = DocumentFile.fromTreeUri(context, parent); - } - - this.sourceTree = parent.toString(); - } - - this.srcName = getName(); - this.srcType = getType(); - } - - - public static StoredFileHelper deserialize(@NonNull final StoredFileHelper storage, - final Context context) throws IOException { - final Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); - - if (storage.isInvalid()) { - return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); - } - - final StoredFileHelper instance = new StoredFileHelper(context, treeUri, - Uri.parse(storage.source), storage.tag); - - // under SAF, if the target document is deleted, conserve the filename and mime - if (instance.srcName == null) { - instance.srcName = storage.srcName; - } - if (instance.srcType == null) { - instance.srcType = storage.srcType; - } - - return instance; - } - - public SharpStream getStream() throws IOException { - assertValid(); - - if (docFile == null) { - return new FileStream(ioPath.toFile()); - } else { - return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); - } - } - - public SharpStream openAndTruncateStream() throws IOException { - final SharpStream sharpStream = getStream(); - try { - sharpStream.setLength(0); - } catch (final Throwable e) { - // we can't use try-with-resources here, since we only want to close the stream if an - // exception occurs, but leave it open if everything goes well - sharpStream.close(); - throw e; - } - return sharpStream; - } - - /** - * Indicates whether it's using the {@code java.io} API. - * - * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework - */ - public boolean isDirect() { - assertValid(); - - return docFile == null; - } - - public boolean isInvalid() { - return source == null; - } - - public Uri getUri() { - assertValid(); - - return docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri(); - } - - public Uri getParentUri() { - assertValid(); - - return sourceTree == null ? null : Uri.parse(sourceTree); - } - - public void truncate() throws IOException { - assertValid(); - - try (SharpStream fs = getStream()) { - fs.setLength(0); - } - } - - public boolean delete() { - if (source == null) { - return true; - } - if (docFile == null) { - try { - return Files.deleteIfExists(ioPath); - } catch (final IOException e) { - Log.e(TAG, "Exception while deleting " + ioPath, e); - return false; - } - } - - final boolean res = docFile.delete(); - - try { - final int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); - } catch (final Exception ex) { - // nothing to do - } - - return res; - } - - public long length() { - assertValid(); - - if (docFile == null) { - try { - return Files.size(ioPath); - } catch (final IOException e) { - Log.e(TAG, "Exception while getting the size of " + ioPath, e); - return 0; - } - } else { - return docFile.length(); - } - } - - public boolean canWrite() { - if (source == null) { - return false; - } - return docFile == null ? Files.isWritable(ioPath) : docFile.canWrite(); - } - - public String getName() { - if (source == null) { - return srcName; - } else if (docFile == null) { - return ioPath.getFileName().toString(); - } - - final String name = docFile.getName(); - return name == null ? srcName : name; - } - - public String getType() { - if (source == null || docFile == null) { - return srcType; - } - - final String type = docFile.getType(); - return type == null ? srcType : type; - } - - public String getTag() { - return tag; - } - - public boolean existsAsFile() { - if (source == null || (docFile == null && ioPath == null)) { - if (DEBUG) { - Log.d(TAG, "existsAsFile called but something is null: source = [" - + (source == null ? "null => storage is invalid" : source) - + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]"); - } - return false; - } - - // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow - // docFile.isVirtual() means it is non-physical? - return docFile == null - ? Files.isRegularFile(ioPath) - : (docFile.exists() && docFile.isFile()); - } - - public boolean create() { - assertValid(); - final boolean result; - - if (docFile == null) { - try { - Files.createFile(ioPath); - result = true; - } catch (final IOException e) { - Log.e(TAG, "Exception while creating " + ioPath, e); - return false; - } - } else if (docTree == null) { - result = false; - } else { - if (!docTree.canRead() || !docTree.canWrite()) { - return false; - } - try { - docFile = createSAF(context, srcType, srcName); - if (docFile.getName() == null) { - return false; - } - result = true; - } catch (final IOException e) { - return false; - } - } - - if (result) { - source = (docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri()) - .toString(); - srcName = getName(); - srcType = getType(); - } - - return result; - } - - public void invalidate() { - if (source == null) { - return; - } - - srcName = getName(); - srcType = getType(); - - source = null; - - docTree = null; - docFile = null; - ioPath = null; - context = null; - } - - public boolean equals(final StoredFileHelper storage) { - if (this == storage) { - return true; - } - - // note: do not compare tags, files can have the same parent folder - //if (stringMismatch(this.tag, storage.tag)) return false; - - if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) { - return false; - } - - if (this.isInvalid() || storage.isInvalid()) { - if (this.srcName == null || storage.srcName == null || this.srcType == null - || storage.srcType == null) { - return false; - } - - return this.srcName.equalsIgnoreCase(storage.srcName) - && this.srcType.equalsIgnoreCase(storage.srcType); - } - - if (this.isDirect() != storage.isDirect()) { - return false; - } - - if (this.isDirect()) { - return this.ioPath.equals(storage.ioPath); - } - - return DocumentsContract.getDocumentId(this.docFile.getUri()) - .equalsIgnoreCase(DocumentsContract.getDocumentId(storage.docFile.getUri())); - } - - @NonNull - @Override - public String toString() { - if (source == null) { - return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; - } else { - return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) - + " tag=" + tag; - } - } - - - private void assertValid() { - if (source == null) { - throw new IllegalStateException("In invalid state"); - } - } - - private void takePermissionSAF() throws IOException { - try { - context.getContentResolver().takePersistableUriPermission(docFile.getUri(), - StoredDirectoryHelper.PERMISSION_FLAGS); - } catch (final Exception e) { - if (docFile.getName() == null) { - throw new IOException(e); - } - } - } - - @NonNull - private DocumentFile createSAF(@Nullable final Context ctx, final String mime, - final String filename) throws IOException { - DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(ctx, docTree, filename); - - if (res != null && res.exists() && res.isDirectory()) { - if (!res.delete()) { - throw new IOException("Directory with the same name found but cannot delete"); - } - res = null; - } - - if (res == null) { - res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); - if (res == null) { - throw new IOException("Cannot create the file"); - } - } - - return res; - } - - private String getLowerCase(final String str) { - return str == null ? null : str.toLowerCase(); - } - - private boolean stringMismatch(final String str1, final String str2) { - if (str1 == null && str2 == null) { - return false; - } - if ((str1 == null) != (str2 == null)) { - return true; - } - - return !str1.equals(str2); - } - - public static Intent getPicker(@NonNull final Context ctx, - @NonNull final String mimeType) { - if (NewPipeSettings.useStorageAccessFramework(ctx)) { - return new Intent(Intent.ACTION_OPEN_DOCUMENT) - .putExtra("android.content.extra.SHOW_ADVANCED", true) - .setType(mimeType) - .addCategory(Intent.CATEGORY_OPENABLE) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | StoredDirectoryHelper.PERMISSION_FLAGS); - } else { - return new Intent(ctx, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_FILE); - } - } - - public static Intent getPicker(@NonNull final Context ctx, - @NonNull final String mimeType, - @Nullable final Uri initialPath) { - return applyInitialPathToPickerIntent(ctx, getPicker(ctx, mimeType), initialPath, null); - } - - public static Intent getNewPicker(@NonNull final Context ctx, - @Nullable final String filename, - @NonNull final String mimeType, - @Nullable final Uri initialPath) { - final Intent i; - if (NewPipeSettings.useStorageAccessFramework(ctx)) { - i = new Intent(Intent.ACTION_CREATE_DOCUMENT) - .putExtra("android.content.extra.SHOW_ADVANCED", true) - .setType(mimeType) - .addCategory(Intent.CATEGORY_OPENABLE) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | StoredDirectoryHelper.PERMISSION_FLAGS); - if (filename != null) { - i.putExtra(Intent.EXTRA_TITLE, filename); - } - } else { - i = new Intent(ctx, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_NEW_FILE); - } - return applyInitialPathToPickerIntent(ctx, i, initialPath, filename); - } - - private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx, - @NonNull final Intent intent, - @Nullable final Uri initialPath, - @Nullable final String filename) { - - if (NewPipeSettings.useStorageAccessFramework(ctx)) { - if (initialPath == null) { - return intent; // nothing to do, no initial path provided - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath); - } else { - return intent; // can't set initial path on API < 26 - } - - } else { - if (initialPath == null && filename == null) { - return intent; // nothing to do, no initial path and no file name provided - } - - File file; - if (initialPath == null) { - // The only way to set the previewed filename in non-SAF FilePicker is to set a - // starting path ending with that filename. So when the initialPath is null but - // filename isn't just default to the external storage directory. - file = Environment.getExternalStorageDirectory(); - } else { - try { - file = Utils.getFileForUri(initialPath); - } catch (final Throwable ignored) { - // getFileForUri() can't decode paths to 'storage', fallback to this - file = new File(initialPath.toString()); - } - } - - // remove any filename at the end of the path (get the parent directory in that case) - if (!file.exists() || !file.isDirectory()) { - file = file.getParentFile(); - if (file == null || !file.exists()) { - // default to the external storage directory in case of an invalid path - file = Environment.getExternalStorageDirectory(); - } - // else: file is surely a directory - } - - if (filename != null) { - // append a filename so that the non-SAF FilePicker shows it as the preview - file = new File(file, filename); - } - - return intent - .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java deleted file mode 100644 index 90689052e..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; - -import java.io.Serializable; -import java.util.List; -import java.util.stream.Collectors; - -/** - * A list adapter for groups of {@link AudioStream}s (audio tracks). - */ -public class AudioTrackAdapter extends BaseAdapter { - private final AudioTracksWrapper tracksWrapper; - - public AudioTrackAdapter(final AudioTracksWrapper tracksWrapper) { - this.tracksWrapper = tracksWrapper; - } - - @Override - public int getCount() { - return tracksWrapper.size(); - } - - @Override - public List getItem(final int position) { - return tracksWrapper.getTracksList().get(position).getStreamsList(); - } - - @Override - public long getItemId(final int position) { - return position; - } - - @Override - public View getView(final int position, final View convertView, final ViewGroup parent) { - final var context = parent.getContext(); - final View view; - if (convertView == null) { - view = LayoutInflater.from(context).inflate( - R.layout.stream_quality_item, parent, false); - } else { - view = convertView; - } - - final ImageView woSoundIconView = view.findViewById(R.id.wo_sound_icon); - final TextView formatNameView = view.findViewById(R.id.stream_format_name); - final TextView qualityView = view.findViewById(R.id.stream_quality); - final TextView sizeView = view.findViewById(R.id.stream_size); - - final List streams = getItem(position); - final AudioStream stream = streams.get(0); - - woSoundIconView.setVisibility(View.GONE); - sizeView.setVisibility(View.VISIBLE); - - if (stream.getAudioTrackId() != null) { - formatNameView.setText(stream.getAudioTrackId()); - } - qualityView.setText(Localization.audioTrackName(context, stream)); - - return view; - } - - public static class AudioTracksWrapper implements Serializable { - private final List> tracksList; - - public AudioTracksWrapper(@NonNull final List> groupedAudioStreams, - @Nullable final Context context) { - this.tracksList = groupedAudioStreams.stream().map(streams -> - new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList()); - } - - public List> getTracksList() { - return tracksList; - } - - public int size() { - return tracksList.size(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java b/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java deleted file mode 100644 index aeda4717c..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.os.Bundle; -import android.os.Parcelable; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.evernote.android.state.StateSaver; -import com.livefront.bridge.Bridge; -import com.livefront.bridge.SavedStateHandler; -import com.livefront.bridge.ViewSavedStateHandler; - -/** - * Configures Bridge's state saver. - */ -public final class BridgeStateSaverInitializer { - - public static void init(final Context context) { - Bridge.initialize( - context, - new SavedStateHandler() { - @Override - public void saveInstanceState( - @NonNull final Object target, - @NonNull final Bundle state) { - StateSaver.saveInstanceState(target, state); - } - - @Override - public void restoreInstanceState( - @NonNull final Object target, - @Nullable final Bundle state) { - StateSaver.restoreInstanceState(target, state); - } - }, - new ViewSavedStateHandler() { - @NonNull - @Override - public Parcelable saveInstanceState( - @NonNull final T target, - @Nullable final Parcelable parentState) { - return StateSaver.saveInstanceState(target, parentState); - } - - @Nullable - @Override - public Parcelable restoreInstanceState( - @NonNull final T target, - @Nullable final Parcelable state) { - return StateSaver.restoreInstanceState(target, state); - } - } - ); - } - - private BridgeStateSaverInitializer() { - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java deleted file mode 100644 index cde6e3fef..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.StringRes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; - -import java.util.List; -import java.util.Set; - -public final class ChannelTabHelper { - private ChannelTabHelper() { - } - - /** - * @param tab the channel tab to check - * @return whether the tab should contain (playable) streams or not - */ - public static boolean isStreamsTab(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - case ChannelTabs.TRACKS: - case ChannelTabs.LIKES: - case ChannelTabs.SHORTS: - case ChannelTabs.LIVESTREAMS: - return true; - default: - return false; - } - } - - /** - * @param tab the channel tab link handler to check - * @return whether the tab should contain (playable) streams or not - */ - public static boolean isStreamsTab(final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); - if (contentFilters.isEmpty()) { - return false; // this should never happen, but check just to be sure - } else { - return isStreamsTab(contentFilters.get(0)); - } - } - - @StringRes - private static int getShowTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.show_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.show_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.show_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.show_channel_tabs_livestreams; - case ChannelTabs.CHANNELS: - return R.string.show_channel_tabs_channels; - case ChannelTabs.PLAYLISTS: - return R.string.show_channel_tabs_playlists; - case ChannelTabs.ALBUMS: - return R.string.show_channel_tabs_albums; - case ChannelTabs.LIKES: - return R.string.show_channel_tabs_likes; - default: - return -1; - } - } - - @StringRes - private static int getFetchFeedTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.fetch_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.fetch_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.fetch_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.fetch_channel_tabs_livestreams; - case ChannelTabs.LIKES: - return R.string.fetch_channel_tabs_likes; - default: - return -1; - } - } - - @StringRes - public static int getTranslationKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.channel_tab_videos; - case ChannelTabs.TRACKS: - return R.string.channel_tab_tracks; - case ChannelTabs.SHORTS: - return R.string.channel_tab_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.channel_tab_livestreams; - case ChannelTabs.CHANNELS: - return R.string.channel_tab_channels; - case ChannelTabs.PLAYLISTS: - return R.string.channel_tab_playlists; - case ChannelTabs.ALBUMS: - return R.string.channel_tab_albums; - case ChannelTabs.LIKES: - return R.string.channel_tab_likes; - default: - return R.string.unknown_content; - } - } - - public static boolean showChannelTab(final Context context, - final SharedPreferences sharedPreferences, - @StringRes final int key) { - final Set enabledTabs = sharedPreferences.getStringSet( - context.getString(R.string.show_channel_tabs_key), null); - if (enabledTabs == null) { - return true; // default to true - } else { - return enabledTabs.contains(context.getString(key)); - } - } - - public static boolean showChannelTab(final Context context, - final SharedPreferences sharedPreferences, - final String tab) { - final int key = ChannelTabHelper.getShowTabKey(tab); - if (key == -1) { - return false; - } - return showChannelTab(context, sharedPreferences, key); - } - - public static boolean fetchFeedChannelTab(final Context context, - final SharedPreferences sharedPreferences, - final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); - if (contentFilters.isEmpty()) { - return false; // this should never happen, but check just to be sure - } - - final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0)); - if (key == -1) { - return false; - } - - final Set enabledTabs = sharedPreferences.getStringSet( - context.getString(R.string.feed_fetch_channel_tabs_key), null); - if (enabledTabs == null) { - return true; // default to true - } else { - return enabledTabs.contains(context.getString(key)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/Constants.kt b/app/src/main/java/org/schabi/newpipe/util/Constants.kt deleted file mode 100644 index 054aadd70..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/Constants.kt +++ /dev/null @@ -1,20 +0,0 @@ -@file:JvmName("Constants") - -package org.schabi.newpipe.util - -/** - * Default duration when using throttle functions across the app, in milliseconds. - */ -const val DEFAULT_THROTTLE_TIMEOUT = 120L - -const val KEY_SERVICE_ID = "key_service_id" -const val KEY_URL = "key_url" -const val KEY_TITLE = "key_title" -const val KEY_LINK_TYPE = "key_link_type" -const val KEY_OPEN_SEARCH = "key_open_search" -const val KEY_SEARCH_STRING = "key_search_string" - -const val KEY_THEME_CHANGE = "key_theme_change" -const val KEY_MAIN_PAGE_CHANGE = "key_main_page_change" - -const val NO_SERVICE_ID = -1 diff --git a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt deleted file mode 100644 index 8e73a94f2..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import androidx.preference.PreferenceManager -import org.schabi.newpipe.R - -/** - * For preferences with dependencies and multiple use case, - * this class can be used to reduce the lines of code. - */ -object DependentPreferenceHelper { - /** - * Option `Resume playback` depends on `Watch history`, this method can be used to retrieve if - * `Resume playback` and its dependencies are all enabled. - * - * @param context the Android context - * @return returns true if `Resume playback` and `Watch history` are both enabled - */ - @JvmStatic - fun getResumePlaybackEnabled(context: Context): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - - return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) && - prefs.getBoolean(context.getString(R.string.enable_playback_resume_key), true) - } - - /** - * Option `Position in lists` depends on `Watch history`, this method can be used to retrieve if - * `Position in lists` and its dependencies are all enabled. - * - * @param context the Android context - * @return returns true if `Positions in lists` and `Watch history` are both enabled - */ - @JvmStatic - fun getPositionsInListsEnabled(context: Context): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - - return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) && - prefs.getBoolean(context.getString(R.string.enable_playback_state_lists_key), true) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java deleted file mode 100644 index 83152a36d..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ /dev/null @@ -1,352 +0,0 @@ -package org.schabi.newpipe.util; - -import static android.content.Context.INPUT_SERVICE; - -import android.annotation.SuppressLint; -import android.app.UiModeManager; -import android.content.Context; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Point; -import android.hardware.input.InputManager; -import android.os.BatteryManager; -import android.os.Build; -import android.provider.Settings; -import android.util.TypedValue; -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.webkit.CookieManager; - -import androidx.annotation.Dimension; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; - -import java.lang.reflect.Method; - -public final class DeviceUtils { - - private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; - private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung"); - private static Boolean isTV = null; - private static Boolean isFireTV = null; - - /** - *

The app version code that corresponds to the last update - * of the media tunneling device blacklist.

- *

The value of this variable needs to be updated everytime a new device that does not - * support media tunneling to match the upcoming version code.

- * @see #shouldSupportMediaTunneling() - */ - public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994; - - // region: devices not supporting media tunneling / media tunneling blacklist - /** - *

Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo.

- *

Blacklist reason: black screen

- *

Board: HiSilicon Hi3798MV200

- */ - private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24 - && Build.DEVICE.equals("Hi3798MV200"); - /** - *

Zephir TS43UHD-2.

- *

Blacklist reason: black screen

- */ - private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 - && Build.DEVICE.equals("cvt_mt5886_eu_1g"); - /** - * Hilife TV. - *

Blacklist reason: black screen

- */ - private static final boolean REALTEKATV = Build.VERSION.SDK_INT == 25 - && Build.DEVICE.equals("RealtekATV"); - /** - *

Phillips 4K (O)LED TV.

- * Supports custom ROMs with different API levels - */ - private static final boolean PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26 - && Build.DEVICE.equals("PH7M_EU_5596"); - /** - *

Philips QM16XE.

- *

Blacklist reason: black screen

- */ - private static final boolean QM16XE_U = Build.VERSION.SDK_INT == 23 - && Build.DEVICE.equals("QM16XE_U"); - /** - *

Sony Bravia VH1.

- *

Processor: MT5895

- *

Blacklist reason: fullscreen crash / stuttering

- */ - private static final boolean BRAVIA_VH1 = Build.VERSION.SDK_INT == 29 - && Build.DEVICE.equals("BRAVIA_VH1"); - /** - *

Sony Bravia VH2.

- *

Blacklist reason: fullscreen crash; this includes model A90J as reported in - * - * #9023

- */ - private static final boolean BRAVIA_VH2 = Build.VERSION.SDK_INT == 29 - && Build.DEVICE.equals("BRAVIA_VH2"); - /** - *

Sony Bravia Android TV platform 2.

- * Uses a MediaTek MT5891 (MT5596) SoC. - * @see - * https://github.com/CiNcH83/bravia_atv2 - */ - private static final boolean BRAVIA_ATV2 = Build.DEVICE.equals("BRAVIA_ATV2"); - /** - *

Sony Bravia Android TV platform 3 4K.

- *

Uses ARM MT5891 and a {@link #BRAVIA_ATV2} motherboard.

- * - * @see - * https://browser.geekbench.com/v4/cpu/9101105 - */ - private static final boolean BRAVIA_ATV3_4K = Build.DEVICE.equals("BRAVIA_ATV3_4K"); - /** - *

Panasonic 4KTV-JUP.

- *

Blacklist reason: fullscreen crash

- */ - private static final boolean TX_50JXW834 = Build.DEVICE.equals("TX_50JXW834"); - /** - *

Bouygtel4K / Bouygues Telecom Bbox 4K.

- *

Blacklist reason: black screen; reported at - * - * #10122

- */ - private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW"); - // endregion - - private DeviceUtils() { - } - - public static boolean isFireTv() { - if (isFireTV != null) { - return isFireTV; - } - - isFireTV = - App.getInstance().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV); - return isFireTV; - } - - public static boolean isTv(final Context context) { - if (isTV != null) { - return isTV; - } - - final PackageManager pm = App.getInstance().getPackageManager(); - - // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check - boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) - .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION - || isFireTv() - || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); - - // from https://stackoverflow.com/a/58932366 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - final boolean isBatteryAbsent = context.getSystemService(BatteryManager.class) - .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) == 0; - isTv = isTv || (isBatteryAbsent - && !pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) - && pm.hasSystemFeature(PackageManager.FEATURE_USB_HOST) - && pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET)); - } - - DeviceUtils.isTV = isTv; - return DeviceUtils.isTV; - } - - /** - * Checks if the device is in desktop or DeX mode. This function should only - * be invoked once on view load as it is using reflection for the DeX checks. - * @param context the context to use for services and config. - * @return true if the Android device is in desktop mode or using DeX. - */ - @SuppressWarnings("JavaReflectionMemberAccess") - public static boolean isDesktopMode(@NonNull final Context context) { - // Adapted from https://stackoverflow.com/a/64615568 - // to check for all input devices that have an active cursor - final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE); - for (final int id : im.getInputDeviceIds()) { - final InputDevice inputDevice = im.getInputDevice(id); - if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS) - || inputDevice.supportsSource(InputDevice.SOURCE_MOUSE) - || inputDevice.supportsSource(InputDevice.SOURCE_STYLUS) - || inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD) - || inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) { - return true; - } - } - - final UiModeManager uiModeManager = - ContextCompat.getSystemService(context, UiModeManager.class); - if (uiModeManager != null - && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) { - return true; - } - - if (!SAMSUNG) { - return false; - // DeX is Samsung-specific, skip the checks below on non-Samsung devices - } - // DeX check for standalone and multi-window mode, from: - // https://developer.samsung.com/samsung-dex/modify-optimizing.html - try { - final Configuration config = context.getResources().getConfiguration(); - final Class configClass = config.getClass(); - final int semDesktopModeEnabledConst = - configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass); - final int currentMode = - configClass.getField("semDesktopModeEnabled").getInt(config); - if (semDesktopModeEnabledConst == currentMode) { - return true; - } - } catch (final NoSuchFieldException | IllegalAccessException ignored) { - // Device doesn't seem to support DeX - } - - @SuppressLint("WrongConstant") final Object desktopModeManager = context - .getApplicationContext() - .getSystemService("desktopmode"); - - if (desktopModeManager != null) { - try { - final Method getDesktopModeStateMethod = desktopModeManager.getClass() - .getDeclaredMethod("getDesktopModeState"); - final Object desktopModeState = getDesktopModeStateMethod - .invoke(desktopModeManager); - final Class desktopModeStateClass = desktopModeState.getClass(); - final Method getEnabledMethod = desktopModeStateClass - .getDeclaredMethod("getEnabled"); - final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState); - if (enabledStatus == desktopModeStateClass - .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { - return true; - } - } catch (final Exception ignored) { - // Device does not support DeX 3.0 or something went wrong when trying to determine - // if it supports this feature - } - } - - return false; - } - - public static boolean isTablet(@NonNull final Context context) { - final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) - .getString(context.getString(R.string.tablet_mode_key), ""); - - if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_on_key))) { - return true; - } else if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_off_key))) { - return false; - } - - // else automatically determine whether we are in a tablet or not - return (context.getResources().getConfiguration().screenLayout - & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; - } - - public static boolean isConfirmKey(final int keyCode) { - switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_CENTER: - case KeyEvent.KEYCODE_ENTER: - case KeyEvent.KEYCODE_SPACE: - case KeyEvent.KEYCODE_NUMPAD_ENTER: - return true; - default: - return false; - } - } - - public static int dpToPx(@Dimension(unit = Dimension.DP) final int dp, - @NonNull final Context context) { - return (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - dp, - context.getResources().getDisplayMetrics()); - } - - public static int spToPx(@Dimension(unit = Dimension.SP) final int sp, - @NonNull final Context context) { - return (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, - sp, - context.getResources().getDisplayMetrics()); - } - - public static boolean isLandscape(final Context context) { - return context.getResources().getDisplayMetrics().heightPixels < context.getResources() - .getDisplayMetrics().widthPixels; - } - - public static boolean isInMultiWindow(final AppCompatActivity activity) { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode(); - } - - public static boolean hasAnimationsAnimatorDurationEnabled(final Context context) { - return Settings.System.getFloat( - context.getContentResolver(), - Settings.Global.ANIMATOR_DURATION_SCALE, - 1F) != 0F; - } - - public static int getWindowHeight(@NonNull final WindowManager windowManager) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - final var windowMetrics = windowManager.getCurrentWindowMetrics(); - final var windowInsets = windowMetrics.getWindowInsets(); - final var insets = windowInsets.getInsetsIgnoringVisibility( - WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); - return windowMetrics.getBounds().height() - (insets.top + insets.bottom); - } else { - final Point point = new Point(); - windowManager.getDefaultDisplay().getSize(point); - return point.y; - } - } - - /** - *

Some devices have broken tunneled video playback but claim to support it.

- *

This can cause a black video player surface while attempting to play a video or - * crashes while entering or exiting the full screen player. - * The issue effects Android TVs most commonly. - * See #5911 and - * #9023 for more info.

- * @Note Update {@link #MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION} - * when adding a new device to the method. - * @return {@code false} if affected device; {@code true} otherwise - */ - public static boolean shouldSupportMediaTunneling() { - // Maintainers note: update MEDIA_TUNNELING_DEVICES_UPDATE_APP_VERSION_CODE - return !HI3798MV200 - && !CVT_MT5886_EU_1G - && !REALTEKATV - && !QM16XE_U - && !BRAVIA_VH1 - && !BRAVIA_VH2 - && !BRAVIA_ATV2 - && !BRAVIA_ATV3_4K - && !PH7M_EU_5596 - && !TX_50JXW834 - && !HMB9213NW; - } - - /** - * @return whether the device has support for WebView, see - * https://stackoverflow.com/a/69626735 - */ - public static boolean supportsWebView() { - try { - CookieManager.getInstance(); - return true; - } catch (final Throwable ignored) { - return false; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java deleted file mode 100644 index 066d5f570..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ /dev/null @@ -1,358 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * ExtractorHelper.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; - -import android.content.Context; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; -import org.schabi.newpipe.extractor.MetaInfo; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.kiosk.KioskInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.search.SearchInfo; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; -import org.schabi.newpipe.util.text.TextLinkifier; - -import java.util.Collections; -import java.util.List; - -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public final class ExtractorHelper { - private static final String TAG = ExtractorHelper.class.getSimpleName(); - private static final InfoCache CACHE = InfoCache.getInstance(); - - private ExtractorHelper() { - //no instance - } - - private static void checkServiceId(final int serviceId) { - if (serviceId == Constants.NO_SERVICE_ID) { - throw new IllegalArgumentException("serviceId is NO_SERVICE_ID"); - } - } - - public static Single searchFor(final int serviceId, final String searchString, - final List contentFilter, - final String sortFilter) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - SearchInfo.getInfo(NewPipe.getService(serviceId), - NewPipe.getService(serviceId) - .getSearchQHFactory() - .fromQuery(searchString, contentFilter, sortFilter))); - } - - public static Single> getMoreSearchItems( - final int serviceId, - final String searchString, - final List contentFilter, - final String sortFilter, - final Page page) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - SearchInfo.getMoreItems(NewPipe.getService(serviceId), - NewPipe.getService(serviceId) - .getSearchQHFactory() - .fromQuery(searchString, contentFilter, sortFilter), page)); - - } - - public static Single> suggestionsFor(final int serviceId, final String query) { - checkServiceId(serviceId); - return Single.fromCallable(() -> { - final SuggestionExtractor extractor = NewPipe.getService(serviceId) - .getSuggestionExtractor(); - return extractor != null - ? extractor.suggestionList(query) - : Collections.emptyList(); - }); - } - - public static Single getStreamInfo(final int serviceId, final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM, - Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single getChannelInfo(final int serviceId, final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL, - Single.fromCallable(() -> - ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single getChannelTab(final int serviceId, - final ListLinkHandler listLinkHandler, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, - listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB, - Single.fromCallable(() -> - ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler))); - } - - public static Single> getMoreChannelTabItems( - final int serviceId, - final ListLinkHandler listLinkHandler, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), - listLinkHandler, nextPage)); - } - - public static Single getCommentsInfo(final int serviceId, - final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS, - Single.fromCallable(() -> - CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single> getMoreCommentItems( - final int serviceId, - final CommentsInfo info, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); - } - - public static Single> getMoreCommentItems( - final int serviceId, - final String url, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); - } - - public static Single getPlaylistInfo(final int serviceId, - final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST, - Single.fromCallable(() -> - PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single> getMorePlaylistItems(final int serviceId, - final String url, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); - } - - public static Single getKioskInfo(final int serviceId, - final String url, - final boolean forceLoad) { - return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK, - Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single> getMoreKioskItems(final int serviceId, - final String url, - final Page nextPage) { - return Single.fromCallable(() -> - KioskInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); - } - - /*////////////////////////////////////////////////////////////////////////// - // Cache - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Check if we can load it from the cache (forceLoad parameter), if we can't, - * load from the network (Single loadFromNetwork) - * and put the results in the cache. - * - * @param the item type's class that extends {@link Info} - * @param forceLoad whether to force loading from the network instead of from the cache - * @param serviceId the service to load from - * @param url the URL to load - * @param cacheType the {@link InfoCache.Type} of the item - * @param loadFromNetwork the {@link Single} to load the item from the network - * @return a {@link Single} that loads the item - */ - private static Single checkCache(final boolean forceLoad, - final int serviceId, - @NonNull final String url, - @NonNull final InfoCache.Type cacheType, - @NonNull final Single loadFromNetwork) { - checkServiceId(serviceId); - final Single actualLoadFromNetwork = loadFromNetwork - .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType)); - - final Single load; - if (forceLoad) { - CACHE.removeInfo(serviceId, url, cacheType); - load = actualLoadFromNetwork; - } else { - load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType), - actualLoadFromNetwork.toMaybe()) - .firstElement() // Take the first valid - .toSingle(); - } - - return load; - } - - /** - * Default implementation uses the {@link InfoCache} to get cached results. - * - * @param the item type's class that extends {@link Info} - * @param serviceId the service to load from - * @param url the URL to load - * @param cacheType the {@link InfoCache.Type} of the item - * @return a {@link Single} that loads the item - */ - private static Maybe loadFromCache( - final int serviceId, - @NonNull final String url, - @NonNull final InfoCache.Type cacheType) { - checkServiceId(serviceId); - return Maybe.defer(() -> { - //noinspection unchecked - final I info = (I) CACHE.getFromKey(serviceId, url, cacheType); - if (MainActivity.DEBUG) { - Log.d(TAG, "loadFromCache() called, info > " + info); - } - - // Only return info if it's not null (it is cached) - if (info != null) { - return Maybe.just(info); - } - - return Maybe.empty(); - }); - } - - public static boolean isCached(final int serviceId, - @NonNull final String url, - @NonNull final InfoCache.Type cacheType) { - return null != loadFromCache(serviceId, url, cacheType).blockingGet(); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Formats the text contained in the meta info list as HTML and puts it into the text view, - * while also making the separator visible. If the list is null or empty, or the user chose not - * to see meta information, both the text view and the separator are hidden - * - * @param metaInfos a list of meta information, can be null or empty - * @param metaInfoTextView the text view in which to show the formatted HTML - * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - */ - public static void showMetaInfoInTextView(@Nullable final List metaInfos, - final TextView metaInfoTextView, - final View metaInfoSeparator, - final CompositeDisposable disposables) { - final Context context = metaInfoTextView.getContext(); - if (metaInfos == null || metaInfos.isEmpty() - || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( - context.getString(R.string.show_meta_info_key), true)) { - metaInfoTextView.setVisibility(View.GONE); - metaInfoSeparator.setVisibility(View.GONE); - - } else { - final StringBuilder stringBuilder = new StringBuilder(); - for (final MetaInfo metaInfo : metaInfos) { - if (!isNullOrEmpty(metaInfo.getTitle())) { - stringBuilder.append("").append(metaInfo.getTitle()).append("") - .append(Localization.DOT_SEPARATOR); - } - - String content = metaInfo.getContent().getContent().trim(); - if (content.endsWith(".")) { - content = content.substring(0, content.length() - 1); // remove . at end - } - stringBuilder.append(content); - - for (int i = 0; i < metaInfo.getUrls().size(); i++) { - if (i == 0) { - stringBuilder.append(Localization.DOT_SEPARATOR); - } else { - stringBuilder.append("

"); - } - - stringBuilder - .append("") - .append(capitalizeIfAllUppercase(metaInfo.getUrlTexts().get(i).trim())) - .append(""); - } - } - - metaInfoSeparator.setVisibility(View.VISIBLE); - TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), - HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, - SET_LINK_MOVEMENT_METHOD); - } - } - - private static String capitalizeIfAllUppercase(final String text) { - for (int i = 0; i < text.length(); i++) { - if (Character.isLowerCase(text.charAt(i))) { - return text; // there is at least a lowercase letter -> not all uppercase - } - } - - if (text.isEmpty()) { - return text; - } else { - return text.substring(0, 1).toUpperCase() + text.substring(1).toLowerCase(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java b/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java deleted file mode 100644 index 967a54f0a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.schabi.newpipe.util; - -import android.view.View; - -import androidx.recyclerview.widget.RecyclerView; - -public class FallbackViewHolder extends RecyclerView.ViewHolder { - public FallbackViewHolder(final View itemView) { - super(itemView); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java deleted file mode 100644 index d7fb39651..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ /dev/null @@ -1,147 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SortedList; - -import com.nononsenseapps.filepicker.AbstractFilePickerFragment; -import com.nononsenseapps.filepicker.FilePickerFragment; - -import org.schabi.newpipe.R; - -import java.io.File; - -public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { - private CustomFilePickerFragment currentFragment; - - public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) { - if (uri.getAuthority() == null) { - return false; - } - return uri.getAuthority().startsWith(context.getPackageName()); - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - if (ThemeHelper.isLightThemeSelected(this)) { - this.setTheme(R.style.FilePickerThemeLight); - } else { - this.setTheme(R.style.FilePickerThemeDark); - } - super.onCreate(savedInstanceState); - } - - @Override - public void onBackPressed() { - // If at top most level, normal behaviour - if (currentFragment.isBackTop()) { - super.onBackPressed(); - } else { - // Else go up - currentFragment.goUp(); - } - } - - @Override - protected AbstractFilePickerFragment getFragment(@Nullable final String startPath, - final int mode, - final boolean allowMultiple, - final boolean allowCreateDir, - final boolean allowExistingFile, - final boolean singleClick) { - final CustomFilePickerFragment fragment = new CustomFilePickerFragment(); - fragment.setArgs(startPath != null ? startPath - : Environment.getExternalStorageDirectory().getPath(), - mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); - currentFragment = fragment; - return currentFragment; - } - - /*////////////////////////////////////////////////////////////////////////// - // Internal - //////////////////////////////////////////////////////////////////////////*/ - - public static class CustomFilePickerFragment extends FilePickerFragment { - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - return super.onCreateView(inflater, container, savedInstanceState); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType); - - final View view = viewHolder.itemView.findViewById(android.R.id.text1); - if (view instanceof TextView) { - ((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, - getResources().getDimension(R.dimen.file_picker_items_text_size)); - } - - return viewHolder; - } - - @Override - public void onClickOk(@NonNull final View view) { - if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) { - if (mToast != null) { - mToast.cancel(); - } - mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, - Toast.LENGTH_SHORT); - mToast.show(); - return; - } - - super.onClickOk(view); - } - - @Override - protected boolean isItemVisible(@NonNull final File file) { - if (file.isDirectory() && file.isHidden()) { - return true; - } - return super.isItemVisible(file); - } - - public File getBackTop() { - if (getArguments() == null) { - return Environment.getExternalStorageDirectory(); - } - - final String path = getArguments().getString(KEY_START_PATH, "/"); - if (path.contains(Environment.getExternalStorageDirectory().getPath())) { - return Environment.getExternalStorageDirectory(); - } - - return getPath(path); - } - - public boolean isBackTop() { - return compareFiles(mCurrentPath, - getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0; - } - - @Override - public void onLoadFinished(@NonNull final Loader> loader, - final SortedList data) { - super.onLoadFinished(loader, data); - layoutManager.scrollToPosition(0); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt deleted file mode 100644 index 1237a984b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import androidx.preference.PreferenceManager -import java.util.regex.Matcher -import org.schabi.newpipe.R -import org.schabi.newpipe.ktx.getStringSafe - -object FilenameUtils { - private const val CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+" - private const val CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+" - - /** - * #143 #44 #42 #22: make sure that the filename does not contain illegal chars. - * - * @param context the context to retrieve strings and preferences from - * @param title the title to create a filename from - * @return the filename - */ - @JvmStatic - fun createFilename(context: Context, title: String): String { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - - val charsetLd = context.getString(R.string.charset_letters_and_digits_value) - val charsetMs = context.getString(R.string.charset_most_special_value) - val defaultCharset = context.getString(R.string.default_file_charset_value) - - val replacementChar = sharedPreferences.getStringSafe( - context.getString(R.string.settings_file_replacement_character_key), - "_" - ) - val selectedCharset = sharedPreferences.getStringSafe( - context.getString(R.string.settings_file_charset_key), - "" - ).ifEmpty { defaultCharset } - - val charset = when (selectedCharset) { - charsetLd -> CHARSET_ONLY_LETTERS_AND_DIGITS - charsetMs -> CHARSET_MOST_SPECIAL - else -> selectedCharset // Is the user using a custom charset? - } - - return createFilename(title, charset, Matcher.quoteReplacement(replacementChar)) - } - - /** - * Create a valid filename. - * - * @param title the title to create a filename from - * @param invalidCharacters patter matching invalid characters - * @param replacementChar the replacement - * @return the filename - */ - private fun createFilename( - title: String, - invalidCharacters: String, - replacementChar: String - ): String { - return title.replace(invalidCharacters.toRegex(), replacementChar) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java deleted file mode 100644 index b9c91f8a5..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * InfoCache.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.util; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.LruCache; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.extractor.Info; - -import java.util.Map; - -public final class InfoCache { - private final String TAG = getClass().getSimpleName(); - private static final boolean DEBUG = MainActivity.DEBUG; - - private static final InfoCache INSTANCE = new InfoCache(); - private static final int MAX_ITEMS_ON_CACHE = 60; - /** - * Trim the cache to this size. - */ - private static final int TRIM_CACHE_TO = 30; - - private static final LruCache LRU_CACHE = new LruCache<>(MAX_ITEMS_ON_CACHE); - - private InfoCache() { - // no instance - } - - /** - * Identifies the type of {@link Info} to put into the cache. - */ - public enum Type { - STREAM, - CHANNEL, - CHANNEL_TAB, - COMMENTS, - PLAYLIST, - KIOSK, - } - - public static InfoCache getInstance() { - return INSTANCE; - } - - @NonNull - private static String keyOf(final int serviceId, - @NonNull final String url, - @NonNull final Type cacheType) { - return serviceId + ":" + cacheType.ordinal() + ":" + url; - } - - private static void removeStaleCache() { - for (final Map.Entry entry : InfoCache.LRU_CACHE.snapshot().entrySet()) { - final CacheData data = entry.getValue(); - if (data != null && data.isExpired()) { - InfoCache.LRU_CACHE.remove(entry.getKey()); - } - } - } - - @Nullable - private static Info getInfo(@NonNull final String key) { - final CacheData data = InfoCache.LRU_CACHE.get(key); - if (data == null) { - return null; - } - - if (data.isExpired()) { - InfoCache.LRU_CACHE.remove(key); - return null; - } - - return data.info; - } - - @Nullable - public Info getFromKey(final int serviceId, - @NonNull final String url, - @NonNull final Type cacheType) { - if (DEBUG) { - Log.d(TAG, "getFromKey() called with: " - + "serviceId = [" + serviceId + "], url = [" + url + "]"); - } - synchronized (LRU_CACHE) { - return getInfo(keyOf(serviceId, url, cacheType)); - } - } - - public void putInfo(final int serviceId, - @NonNull final String url, - @NonNull final Info info, - @NonNull final Type cacheType) { - if (DEBUG) { - Log.d(TAG, "putInfo() called with: info = [" + info + "]"); - } - - final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); - synchronized (LRU_CACHE) { - final CacheData data = new CacheData(info, expirationMillis); - LRU_CACHE.put(keyOf(serviceId, url, cacheType), data); - } - } - - public void removeInfo(final int serviceId, - @NonNull final String url, - @NonNull final Type cacheType) { - if (DEBUG) { - Log.d(TAG, "removeInfo() called with: " - + "serviceId = [" + serviceId + "], url = [" + url + "]"); - } - synchronized (LRU_CACHE) { - LRU_CACHE.remove(keyOf(serviceId, url, cacheType)); - } - } - - public void clearCache() { - if (DEBUG) { - Log.d(TAG, "clearCache() called"); - } - synchronized (LRU_CACHE) { - LRU_CACHE.evictAll(); - } - } - - public void trimCache() { - if (DEBUG) { - Log.d(TAG, "trimCache() called"); - } - synchronized (LRU_CACHE) { - removeStaleCache(); - LRU_CACHE.trimToSize(TRIM_CACHE_TO); - } - } - - public long getSize() { - synchronized (LRU_CACHE) { - return LRU_CACHE.size(); - } - } - - private static final class CacheData { - private final long expireTimestamp; - private final Info info; - - private CacheData(@NonNull final Info info, final long timeoutMillis) { - this.expireTimestamp = System.currentTimeMillis() + timeoutMillis; - this.info = info; - } - - private boolean isExpired() { - return System.currentTimeMillis() > expireTimestamp; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java deleted file mode 100644 index 679f3e042..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.schabi.newpipe.util; - -import android.app.Activity; -import android.view.inputmethod.InputMethodManager; -import android.widget.EditText; - -import androidx.core.content.ContextCompat; - -/** - * Utility class for the Android keyboard. - *

- * See also https://stackoverflow.com/q/1109022 - *

- */ -public final class KeyboardUtil { - private KeyboardUtil() { - } - - public static void showKeyboard(final Activity activity, final EditText editText) { - if (activity == null || editText == null) { - return; - } - - if (editText.requestFocus()) { - final InputMethodManager imm = ContextCompat.getSystemService(activity, - InputMethodManager.class); - if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) { - /* - * Sometimes the keyboard can't be shown because Android's ImeFocusController is in - * a incorrect state e.g. when animations are disabled or the unfocus event of the - * previous view arrives in the wrong moment (see #7647 for details). - * The invalid state can be fixed by to re-focusing the editText. - */ - editText.clearFocus(); - editText.requestFocus(); - - // Try again - imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); - } - } - } - - public static void hideKeyboard(final Activity activity, final EditText editText) { - if (activity == null || editText == null) { - return; - } - - final InputMethodManager imm = ContextCompat.getSystemService(activity, - InputMethodManager.class); - imm.hideSoftInputFromWindow(editText.getWindowToken(), - InputMethodManager.HIDE_NOT_ALWAYS); - - editText.clearFocus(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt deleted file mode 100644 index 1f86f5db7..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import org.schabi.newpipe.R - -object KioskTranslator { - @JvmStatic - fun getTranslatedKioskName(kioskId: String, context: Context): String { - return when (kioskId) { - "Trending" -> context.getString(R.string.trending) - "Top 50" -> context.getString(R.string.top_50) - "New & hot" -> context.getString(R.string.new_and_hot) - "Local" -> context.getString(R.string.local) - "Recently added" -> context.getString(R.string.recently_added) - "Most liked" -> context.getString(R.string.most_liked) - "conferences" -> context.getString(R.string.conferences) - "recent" -> context.getString(R.string.recent) - "live" -> context.getString(R.string.duration_live) - "Featured" -> context.getString(R.string.featured) - "Radio" -> context.getString(R.string.radio) - "trending_gaming" -> context.getString(R.string.trending_gaming) - "trending_music" -> context.getString(R.string.trending_music) - "trending_movies_and_shows" -> context.getString(R.string.trending_movies) - "trending_podcasts_episodes" -> context.getString(R.string.trending_podcasts) - else -> kioskId - } - } - - @JvmStatic - fun getKioskIcon(kioskId: String): Int { - return when (kioskId) { - "Trending", "Top 50", "New & hot", "conferences" -> R.drawable.ic_whatshot - "Local" -> R.drawable.ic_home - "Recently added", "recent" -> R.drawable.ic_add_circle_outline - "Most liked" -> R.drawable.ic_thumb_up - "live" -> R.drawable.ic_live_tv - "Featured" -> R.drawable.ic_stars - "Radio" -> R.drawable.ic_radio - "trending_gaming" -> R.drawable.ic_videogame_asset - "trending_music" -> R.drawable.ic_music_note - "trending_movies_and_shows" -> R.drawable.ic_movie - "trending_podcasts_episodes" -> R.drawable.ic_podcasts - else -> 0 - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java deleted file mode 100644 index 634302b96..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ /dev/null @@ -1,880 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.ServiceList.YouTube; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.net.ConnectivityManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.AudioTrackType; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.VideoStream; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -public final class ListHelper { - // Video format in order of quality. 0=lowest quality, n=highest quality - private static final List VIDEO_FORMAT_QUALITY_RANKING = - List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); - - // Audio format in order of quality. 0=lowest quality, n=highest quality - private static final List AUDIO_FORMAT_QUALITY_RANKING = - List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); - // Audio format in order of efficiency. 0=least efficient, n=most efficient - private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = - List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA); - // Use a Set for better performance - private static final Set HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p"); - // Audio track types in order of priority. 0=lowest, n=highest - private static final List AUDIO_TRACK_TYPE_RANKING = - List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.SECONDARY, AudioTrackType.DUBBED, - AudioTrackType.ORIGINAL); - // Audio track types in order of priority when descriptive audio is preferred. - private static final List AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = - List.of(AudioTrackType.SECONDARY, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL, - AudioTrackType.DESCRIPTIVE); - - /** - * List of supported YouTube Itag ids. - * The original order is kept. - * @see org.schabi.newpipe.extractor.services.youtube.ItagItem - */ - private static final List SUPPORTED_ITAG_IDS = - List.of( - 17, 36, // video v3GPP - 18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4 - 43, 44, 45, 46, // video webm - 171, 172, 139, 140, 141, 249, 250, 251, // audio - 160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only - 278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315 - ); - - private ListHelper() { } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getDefaultResolutionIndex(final Context context, - final List videoStreams) { - final String defaultResolution = computeDefaultResolution(context, - R.string.default_resolution_key, R.string.default_resolution_value); - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @param defaultResolution the default resolution to look for - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getResolutionIndex(final Context context, - final List videoStreams, - final String defaultResolution) { - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getPopupDefaultResolutionIndex(final Context context, - final List videoStreams) { - final String defaultResolution = computeDefaultResolution(context, - R.string.default_popup_resolution_key, R.string.default_popup_resolution_value); - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @param defaultResolution the default resolution to look for - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getPopupResolutionIndex(final Context context, - final List videoStreams, - final String defaultResolution) { - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - public static int getDefaultAudioFormat(final Context context, - final List audioStreams) { - return getAudioIndexByHighestRank(audioStreams, - getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context))); - } - - public static int getDefaultAudioTrackGroup(final Context context, - final List> groupedAudioStreams) { - if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { - return -1; - } - - final Comparator cmp = getAudioTrackComparator(context); - final List highestRanked = groupedAudioStreams.stream() - .max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0))) - .orElse(null); - return groupedAudioStreams.indexOf(highestRanked); - } - - public static int getAudioFormatIndex(final Context context, - final List audioStreams, - @Nullable final String trackId) { - if (trackId != null) { - for (int i = 0; i < audioStreams.size(); i++) { - final AudioStream s = audioStreams.get(i); - if (s.getAudioTrackId() != null - && s.getAudioTrackId().equals(trackId)) { - return i; - } - } - } - return getDefaultAudioFormat(context, audioStreams); - } - - /** - * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} - * list. - * - * @param streamList the original {@link Stream stream} list - * @param deliveryMethod the {@link DeliveryMethod delivery method} - * @param the item type's class that extends {@link Stream} - * @return a {@link Stream stream} list which uses the given delivery method - */ - @NonNull - public static List getStreamsOfSpecifiedDelivery( - @Nullable final List streamList, - final DeliveryMethod deliveryMethod) { - return getFilteredStreamList(streamList, - stream -> stream.getDeliveryMethod() == deliveryMethod); - } - - /** - * Return a {@link Stream} list which only contains URL streams and non-torrent streams. - * - * @param streamList the original stream list - * @param the item type's class that extends {@link Stream} - * @return a stream list which only contains URL streams and non-torrent streams - */ - @NonNull - public static List getUrlAndNonTorrentStreams( - @Nullable final List streamList) { - return getFilteredStreamList(streamList, - stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); - } - - /** - * Return a {@link Stream} list which only contains streams which can be played by the player. - * - *

- * Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details. - * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using - * HLS as their delivery method, since they are not supported by ExoPlayer. - *

- * - * @param the item type's class that extends {@link Stream} - * @param streamList the original stream list - * @param serviceId the service ID from which the streams' list comes from - * @return a stream list which only contains streams that can be played the player - */ - @NonNull - public static List getPlayableStreams( - @Nullable final List streamList, final int serviceId) { - final int youtubeServiceId = YouTube.getServiceId(); - return getFilteredStreamList(streamList, - stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT - && (stream.getDeliveryMethod() != DeliveryMethod.HLS - || stream.getFormat() != MediaFormat.OPUS) - && (serviceId != youtubeServiceId - || stream.getItagItem() == null - || SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id))); - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param context the context to search for the format to give preference - * @param videoStreams the normal videos list - * @param videoOnlyStreams the video-only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - @NonNull - public static List getSortedStreamVideosList( - @NonNull final Context context, - @Nullable final List videoStreams, - @Nullable final List videoOnlyStreams, - final boolean ascendingOrder, - final boolean preferVideoOnlyStreams) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - final boolean showHigherResolutions = preferences.getBoolean( - context.getString(R.string.show_higher_resolutions_key), false); - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_video_format_key, R.string.default_video_format_value); - - return getSortedStreamVideosList(defaultFormat, showHigherResolutions, videoStreams, - videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams); - } - - /** - * Get a sorted list containing a set of default resolution info - * and additional resolution info if showHigherResolutions is true. - * - * @param resources the resources to get the resolutions from - * @param defaultResolutionKey the settings key of the default resolution - * @param additionalResolutionKey the settings key of the additional resolutions - * @param showHigherResolutions if higher resolutions should be included in the sorted list - * @return a sorted list containing the default and maybe additional resolutions - */ - public static List getSortedResolutionList( - final Resources resources, - final int defaultResolutionKey, - final int additionalResolutionKey, - final boolean showHigherResolutions) { - final List resolutions = new ArrayList<>(Arrays.asList( - resources.getStringArray(defaultResolutionKey))); - if (!showHigherResolutions) { - return resolutions; - } - final List additionalResolutions = Arrays.asList( - resources.getStringArray(additionalResolutionKey)); - // keep "best resolution" at the top - resolutions.addAll(1, additionalResolutions); - return resolutions; - } - - public static boolean isHighResolutionSelected(final String selectedResolution, - final int additionalResolutionKey, - final Resources resources) { - return Arrays.asList(resources.getStringArray( - additionalResolutionKey)) - .contains(selectedResolution); - } - - /** - * Filter the list of audio streams and return a list with the preferred stream for - * each audio track. Streams are sorted with the preferred language in the first position. - * - * @param context the context to search for the track to give preference - * @param audioStreams the list of audio streams - * @return the sorted, filtered list - */ - public static List getFilteredAudioStreams( - @NonNull final Context context, - @Nullable final List audioStreams) { - if (audioStreams == null) { - return Collections.emptyList(); - } - - final HashMap collectedStreams = new HashMap<>(); - - final Comparator cmp = getAudioFormatComparator(context); - - for (final AudioStream stream : audioStreams) { - if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT - || (stream.getDeliveryMethod() == DeliveryMethod.HLS - && stream.getFormat() == MediaFormat.OPUS)) { - continue; - } - - final String trackId = Objects.toString(stream.getAudioTrackId(), ""); - - final AudioStream presentStream = collectedStreams.get(trackId); - if (presentStream == null || cmp.compare(stream, presentStream) > 0) { - collectedStreams.put(trackId, stream); - } - } - - // Filter unknown audio tracks if there are multiple tracks - if (collectedStreams.size() > 1) { - collectedStreams.remove(""); - } - - // Sort collected streams by name - return collectedStreams.values().stream().sorted(getAudioTrackNameComparator()) - .collect(Collectors.toList()); - } - - /** - * Group the list of audioStreams by their track ID and sort the resulting list by track name. - * - * @param context app context to get track names for sorting - * @param audioStreams list of audio streams - * @return list of audio streams lists representing individual tracks - */ - public static List> getGroupedAudioStreams( - @NonNull final Context context, - @Nullable final List audioStreams) { - if (audioStreams == null) { - return Collections.emptyList(); - } - - final HashMap> collectedStreams = new HashMap<>(); - - for (final AudioStream stream : audioStreams) { - final String trackId = Objects.toString(stream.getAudioTrackId(), ""); - if (collectedStreams.containsKey(trackId)) { - collectedStreams.get(trackId).add(stream); - } else { - final List list = new ArrayList<>(); - list.add(stream); - collectedStreams.put(trackId, list); - } - } - - // Filter unknown audio tracks if there are multiple tracks - if (collectedStreams.size() > 1) { - collectedStreams.remove(""); - } - - // Sort tracks alphabetically, sort track streams by quality - final Comparator nameCmp = getAudioTrackNameComparator(); - final Comparator formatCmp = getAudioFormatComparator(context); - - return collectedStreams.values().stream() - .sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0))) - .map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList())) - .collect(Collectors.toList()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. - * - * @param streamList the stream list to filter - * @param streamListPredicate the predicate which will be used to filter streams - * @param the item type's class that extends {@link Stream} - * @return a new stream list filtered using the given predicate - */ - private static List getFilteredStreamList( - @Nullable final List streamList, - final Predicate streamListPredicate) { - if (streamList == null) { - return Collections.emptyList(); - } - - return streamList.stream() - .filter(streamListPredicate) - .collect(Collectors.toList()); - } - - private static String computeDefaultResolution(@NonNull final Context context, final int key, - final int value) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - // Load the preferred resolution otherwise the best available - String resolution = preferences != null - ? preferences.getString(context.getString(key), context.getString(value)) - : context.getString(R.string.best_resolution_key); - - final String maxResolution = getResolutionLimit(context); - if (maxResolution != null - && (resolution.equals(context.getString(R.string.best_resolution_key)) - || compareVideoStreamResolution(maxResolution, resolution) < 1)) { - resolution = maxResolution; - } - return resolution; - } - - /** - * Return the index of the default stream in the list, that will be sorted in the process, based - * on the parameters defaultResolution and defaultFormat. - * - * @param defaultResolution the default resolution to look for - * @param bestResolutionKey key of the best resolution - * @param defaultFormat the default format to look for - * @param videoStreams a mutable list of the video streams to check (it will be sorted in - * place) - * @return index of the default resolution&format in the sorted videoStreams - */ - static int getDefaultResolutionIndex(final String defaultResolution, - final String bestResolutionKey, - final MediaFormat defaultFormat, - @Nullable final List videoStreams) { - if (videoStreams == null || videoStreams.isEmpty()) { - return -1; - } - - sortStreamList(videoStreams, false); - if (defaultResolution.equals(bestResolutionKey)) { - return 0; - } - - final int defaultStreamIndex = - getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); - - // this is actually an error, - // but maybe there is really no stream fitting to the default value. - if (defaultStreamIndex == -1) { - return 0; - } - return defaultStreamIndex; - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - @NonNull - static List getSortedStreamVideosList( - @Nullable final MediaFormat defaultFormat, - final boolean showHigherResolutions, - @Nullable final List videoStreams, - @Nullable final List videoOnlyStreams, - final boolean ascendingOrder, - final boolean preferVideoOnlyStreams - ) { - // Determine order of streams - // The last added list is preferred - final List> videoStreamsOrdered = - preferVideoOnlyStreams - ? Arrays.asList(videoStreams, videoOnlyStreams) - : Arrays.asList(videoOnlyStreams, videoStreams); - - final List allInitialStreams = videoStreamsOrdered.stream() - // Ignore lists that are null - .filter(Objects::nonNull) - .flatMap(List::stream) - // Filter out higher resolutions (or not if high resolutions should always be shown) - .filter(stream -> showHigherResolutions - || !HIGH_RESOLUTION_LIST.contains(stream.getResolution() - // Replace any frame rate with nothing - .replaceAll("p\\d+$", "p"))) - .collect(Collectors.toList()); - - final HashMap hashMap = new HashMap<>(); - // Add all to the hashmap - for (final VideoStream videoStream : allInitialStreams) { - hashMap.put(videoStream.getResolution(), videoStream); - } - - // Override the values when the key == resolution, with the defaultFormat - for (final VideoStream videoStream : allInitialStreams) { - if (videoStream.getFormat() == defaultFormat) { - hashMap.put(videoStream.getResolution(), videoStream); - } - } - - // Return the sorted list - return sortStreamList(new ArrayList<>(hashMap.values()), ascendingOrder); - } - - /** - * Sort the streams list depending on the parameter ascendingOrder; - *

- * It works like that:
- * - Take a string resolution, remove the letters, replace "0p60" (for 60fps videos) with "1" - * and sort by the greatest:
- *

-     *      720p     ->  720
-     *      720p60   ->  721
-     *      360p     ->  360
-     *      1080p    ->  1080
-     *      1080p60  ->  1081
-     * 
- * ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 - * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360
- * - * @param videoStreams list that the sorting will be applied - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @return The sorted list (same reference as parameter videoStreams) - */ - private static List sortStreamList(final List videoStreams, - final boolean ascendingOrder) { - // Compares the quality of two video streams. - final Comparator comparator = Comparator.nullsLast(Comparator - .comparing(VideoStream::getResolution, ListHelper::compareVideoStreamResolution) - .thenComparingInt(s -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s.getFormat()))); - Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed()); - return videoStreams; - } - - /** - * Get the audio-stream from the list with the highest rank, depending on the comparator. - * Format will be ignored if it yields no results. - * - * @param audioStreams List of audio streams - * @param comparator The comparator used for determining the max/best/highest ranked value - * @return Index of audio stream that produces the highest ranked result or -1 if not found - */ - static int getAudioIndexByHighestRank(@Nullable final List audioStreams, - final Comparator comparator) { - if (audioStreams == null || audioStreams.isEmpty()) { - return -1; - } - - final AudioStream highestRankedAudioStream = audioStreams.stream() - .max(comparator).orElse(null); - - return audioStreams.indexOf(highestRankedAudioStream); - } - - /** - * Locates a possible match for the given resolution and format in the provided list. - * - *

In this order:

- * - *
    - *
  1. Find a format and resolution match
  2. - *
  3. Find a format and resolution match and ignore the refresh
  4. - *
  5. Find a resolution match
  6. - *
  7. Find a resolution match and ignore the refresh
  8. - *
  9. Find a resolution just below the requested resolution and ignore the refresh
  10. - *
  11. Give up
  12. - *
- * - * @param targetResolution the resolution to look for - * @param targetFormat the format to look for - * @param videoStreams the available video streams - * @return the index of the preferred video stream - */ - static int getVideoStreamIndex(@NonNull final String targetResolution, - final MediaFormat targetFormat, - @NonNull final List videoStreams) { - int fullMatchIndex = -1; - int fullMatchNoRefreshIndex = -1; - int resMatchOnlyIndex = -1; - int resMatchOnlyNoRefreshIndex = -1; - int lowerResMatchNoRefreshIndex = -1; - final String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p"); - - for (int idx = 0; idx < videoStreams.size(); idx++) { - final MediaFormat format = targetFormat == null - ? null - : videoStreams.get(idx).getFormat(); - final String resolution = videoStreams.get(idx).getResolution(); - final String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p"); - - if (format == targetFormat && resolution.equals(targetResolution)) { - fullMatchIndex = idx; - } - - if (format == targetFormat && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { - fullMatchNoRefreshIndex = idx; - } - - if (resMatchOnlyIndex == -1 && resolution.equals(targetResolution)) { - resMatchOnlyIndex = idx; - } - - if (resMatchOnlyNoRefreshIndex == -1 - && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { - resMatchOnlyNoRefreshIndex = idx; - } - - if (lowerResMatchNoRefreshIndex == -1 && compareVideoStreamResolution( - resolutionNoRefresh, targetResolutionNoRefresh) < 0) { - lowerResMatchNoRefreshIndex = idx; - } - } - - if (fullMatchIndex != -1) { - return fullMatchIndex; - } - if (fullMatchNoRefreshIndex != -1) { - return fullMatchNoRefreshIndex; - } - if (resMatchOnlyIndex != -1) { - return resMatchOnlyIndex; - } - if (resMatchOnlyNoRefreshIndex != -1) { - return resMatchOnlyNoRefreshIndex; - } - return lowerResMatchNoRefreshIndex; - } - - /** - * Fetches the desired resolution or returns the default if it is not found. - * The resolution will be reduced if video chocking is active. - * - * @param context Android app context - * @param defaultResolution the default resolution - * @param videoStreams the list of video streams to check - * @return the index of the preferred video stream - */ - private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, - final String defaultResolution, - final List videoStreams) { - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_video_format_key, R.string.default_video_format_value); - return getDefaultResolutionIndex(defaultResolution, - context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); - } - - @Nullable - private static MediaFormat getDefaultFormat(@NonNull final Context context, - @StringRes final int defaultFormatKey, - @StringRes final int defaultFormatValueKey) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - final String defaultFormat = context.getString(defaultFormatValueKey); - final String defaultFormatString = preferences.getString( - context.getString(defaultFormatKey), - defaultFormat - ); - - return getMediaFormatFromKey(context, defaultFormatString); - } - - @Nullable - private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, - @NonNull final String formatKey) { - MediaFormat format = null; - if (formatKey.equals(context.getString(R.string.video_webm_key))) { - format = MediaFormat.WEBM; - } else if (formatKey.equals(context.getString(R.string.video_mp4_key))) { - format = MediaFormat.MPEG_4; - } else if (formatKey.equals(context.getString(R.string.video_3gp_key))) { - format = MediaFormat.v3GPP; - } else if (formatKey.equals(context.getString(R.string.audio_webm_key))) { - format = MediaFormat.WEBMA; - } else if (formatKey.equals(context.getString(R.string.audio_m4a_key))) { - format = MediaFormat.M4A; - } - return format; - } - - private static int compareVideoStreamResolution(@NonNull final String r1, - @NonNull final String r2) { - try { - final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - return res1 - res2; - } catch (final NumberFormatException e) { - // Consider the first one greater because we don't know if the two streams are - // different or not (a NumberFormatException was thrown so we don't know the resolution - // of one stream or of all streams) - return 1; - } - } - - static boolean isLimitingDataUsage(@NonNull final Context context) { - return getResolutionLimit(context) != null; - } - - /** - * The maximum resolution allowed. - * - * @param context App context - * @return maximum resolution allowed or null if there is no maximum - */ - private static String getResolutionLimit(@NonNull final Context context) { - String resolutionLimit = null; - if (isMeteredNetwork(context)) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - final String defValue = context.getString(R.string.limit_data_usage_none_key); - final String value = preferences.getString( - context.getString(R.string.limit_mobile_data_usage_key), defValue); - resolutionLimit = defValue.equals(value) ? null : value; - } - return resolutionLimit; - } - - /** - * The current network is metered (like mobile data)? - * - * @param context App context - * @return {@code true} if connected to a metered network - */ - public static boolean isMeteredNetwork(@NonNull final Context context) { - final ConnectivityManager manager = - ContextCompat.getSystemService(context, ConnectivityManager.class); - if (manager == null || manager.getActiveNetworkInfo() == null) { - return false; - } - - return manager.isActiveNetworkMetered(); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. - * - *

The preferred stream will be ordered last.

- * - * @param context app context - * @return Comparator - */ - private static Comparator getAudioFormatComparator( - final @NonNull Context context) { - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_audio_format_key, R.string.default_audio_format_value); - return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. - * - *

The preferred stream will be ordered last.

- * - * @param defaultFormat the default format to look for - * @param limitDataUsage choose low bitrate audio stream - * @return Comparator - */ - static Comparator getAudioFormatComparator( - @Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) { - final List formatRanking = limitDataUsage - ? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING; - - Comparator bitrateComparator = - Comparator.comparingInt(AudioStream::getAverageBitrate); - if (limitDataUsage) { - bitrateComparator = bitrateComparator.reversed(); - } - - return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> { - if (defaultFormat != null) { - return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat); - } - return 0; - }).thenComparing(bitrateComparator).thenComparingInt( - stream -> formatRanking.indexOf(stream.getFormat())); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. - * - *

Tracks will be compared this order:

- *
    - *
  1. If {@code preferOriginalAudio}: use original audio
  2. - *
  3. Language matches {@code preferredLanguage}
  4. - *
  5. - * Track type ranks highest in this order: - * Original > Dubbed > Descriptive - *

    If {@code preferDescriptiveAudio}: - * Descriptive > Dubbed > Original

    - *
  6. - *
  7. Language is English
  8. - *
- * - *

The preferred track will be ordered last.

- * - * @param context App context - * @return Comparator - */ - private static Comparator getAudioTrackComparator( - @NonNull final Context context) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - final Locale preferredLanguage = Localization.getPreferredLocale(context); - final boolean preferOriginalAudio = - preferences.getBoolean(context.getString(R.string.prefer_original_audio_key), - true); - final boolean preferDescriptiveAudio = - preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key), - false); - - return getAudioTrackComparator(preferredLanguage, preferOriginalAudio, - preferDescriptiveAudio); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. - * - *

Tracks will be compared this order:

- *
    - *
  1. If {@code preferOriginalAudio}: use original audio
  2. - *
  3. Language matches {@code preferredLanguage}
  4. - *
  5. - * Track type ranks highest in this order: - * Original > Dubbed > Descriptive - *

    If {@code preferDescriptiveAudio}: - * Descriptive > Dubbed > Original

    - *
  6. - *
  7. Language is English
  8. - *
- * - *

The preferred track will be ordered last.

- * - * @param preferredLanguage Preferred audio stream language - * @param preferOriginalAudio Get the original audio track regardless of its language - * @param preferDescriptiveAudio Prefer the descriptive audio track if available - * @return Comparator - */ - static Comparator getAudioTrackComparator( - final Locale preferredLanguage, - final boolean preferOriginalAudio, - final boolean preferDescriptiveAudio) { - final String langCode = preferredLanguage.getISO3Language(); - final List trackTypeRanking = preferDescriptiveAudio - ? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING; - - return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> { - if (preferOriginalAudio) { - return Boolean.compare( - o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL); - } - return 0; - }).thenComparing(AudioStream::getAudioLocale, - Comparator.nullsFirst(Comparator.comparing( - locale -> locale.getISO3Language().equals(langCode)))) - .thenComparing(AudioStream::getAudioTrackType, - Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf))) - .thenComparing(AudioStream::getAudioLocale, - Comparator.nullsFirst(Comparator.comparing( - locale -> locale.getISO3Language().equals( - Locale.ENGLISH.getISO3Language())))); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types - * for alphabetical sorting. - * - * @return Comparator - */ - private static Comparator getAudioTrackNameComparator() { - final Locale appLoc = Localization.getAppLocale(); - - return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( - Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) - .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast( - Comparator.naturalOrder())); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java deleted file mode 100644 index 23dd6b2c7..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ /dev/null @@ -1,488 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.icu.text.CompactDecimalFormat; -import android.os.Build; -import android.text.BidiFormatter; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.PluralsRes; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.math.MathUtils; -import androidx.core.os.LocaleListCompat; -import androidx.preference.PreferenceManager; - -import org.ocpsoft.prettytime.PrettyTime; -import org.ocpsoft.prettytime.units.Decade; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.localization.ContentCountry; -import org.schabi.newpipe.extractor.localization.DateWrapper; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.AudioTrackType; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.text.NumberFormat; -import java.time.OffsetDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.stream.Collectors; - - -/* - * Created by chschtsch on 12/29/15. - * - * Copyright (C) Gregory Arkhipov 2015 - * Localization.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public final class Localization { - private static final String TAG = Localization.class.toString(); - public static final String DOT_SEPARATOR = " • "; - private static PrettyTime prettyTime; - - private Localization() { } - - @NonNull - public static String concatenateStrings(final String... strings) { - return concatenateStrings(DOT_SEPARATOR, Arrays.asList(strings)); - } - - @NonNull - public static String concatenateStrings(final String delimiter, final List strings) { - return strings.stream() - .filter(string -> !TextUtils.isEmpty(string)) - .collect(Collectors.joining(delimiter)); - } - - /** - * Localize a user name like @foobar. - * - * Will correctly handle right-to-left usernames by using a {@link BidiFormatter}. - * For right-to-left usernames, it will put the @ on the right side to read more naturally. - * - * @param plainName username, with an optional leading @ - * @return a usernames that can include RTL-characters - */ - @NonNull - public static String localizeUserName(final String plainName) { - return BidiFormatter.getInstance().unicodeWrap(plainName); - } - - public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( - final Context context) { - return org.schabi.newpipe.extractor.localization.Localization - .fromLocale(getPreferredLocale(context)); - } - - public static ContentCountry getPreferredContentCountry(@NonNull final Context context) { - final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context) - .getString(context.getString(R.string.content_country_key), - context.getString(R.string.default_localization_key)); - if (contentCountry.equals(context.getString(R.string.default_localization_key))) { - return new ContentCountry(Locale.getDefault().getCountry()); - } - return new ContentCountry(contentCountry); - } - - public static Locale getPreferredLocale(@NonNull final Context context) { - return getLocaleFromPrefs(context, R.string.content_language_key); - } - - public static Locale getAppLocale() { - final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0); - return customLocale != null ? customLocale : Locale.getDefault(); - } - - public static String localizeNumber(final long number) { - return localizeNumber((double) number); - } - - public static String localizeNumber(final double number) { - return NumberFormat.getInstance(getAppLocale()).format(number); - } - - public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) { - return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(getAppLocale()) - .format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault())); - } - - @SuppressLint("StringFormatInvalid") - public static String localizeUploadDate(@NonNull final Context context, - @NonNull final OffsetDateTime offsetDateTime) { - return context.getString(R.string.upload_date_text, formatDate(offsetDateTime)); - } - - public static String localizeViewCount(@NonNull final Context context, final long viewCount) { - return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - localizeNumber(viewCount)); - } - - public static String localizeStreamCount(@NonNull final Context context, - final long streamCount) { - switch ((int) streamCount) { - case (int) ListExtractor.ITEM_COUNT_UNKNOWN: - return ""; - case (int) ListExtractor.ITEM_COUNT_INFINITE: - return context.getString(R.string.infinite_videos); - case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: - return context.getString(R.string.more_than_100_videos); - default: - return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, - localizeNumber(streamCount)); - } - } - - public static String localizeStreamCountMini(@NonNull final Context context, - final long streamCount) { - switch ((int) streamCount) { - case (int) ListExtractor.ITEM_COUNT_UNKNOWN: - return ""; - case (int) ListExtractor.ITEM_COUNT_INFINITE: - return context.getString(R.string.infinite_videos_mini); - case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: - return context.getString(R.string.more_than_100_videos_mini); - default: - return String.valueOf(streamCount); - } - } - - public static String localizeWatchingCount(@NonNull final Context context, - final long watchingCount) { - return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - localizeNumber(watchingCount)); - } - - public static String shortCount(@NonNull final Context context, final long count) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return CompactDecimalFormat.getInstance(getAppLocale(), - CompactDecimalFormat.CompactStyle.SHORT).format(count); - } - - final double value = (double) count; - if (count >= 1000000000) { - final double shortenedValue = value / 1000000000; - final int scale = shortenedValue >= 100 ? 0 : 1; - return context.getString(R.string.short_billion, - localizeNumber(round(shortenedValue, scale))); - } else if (count >= 1000000) { - final double shortenedValue = value / 1000000; - final int scale = shortenedValue >= 100 ? 0 : 1; - return context.getString(R.string.short_million, - localizeNumber(round(shortenedValue, scale))); - } else if (count >= 1000) { - final double shortenedValue = value / 1000; - final int scale = shortenedValue >= 100 ? 0 : 1; - return context.getString(R.string.short_thousand, - localizeNumber(round(shortenedValue, scale))); - } else { - return localizeNumber(value); - } - } - - public static String listeningCount(@NonNull final Context context, final long listeningCount) { - return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, - shortCount(context, listeningCount)); - } - - public static String shortWatchingCount(@NonNull final Context context, - final long watchingCount) { - return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - shortCount(context, watchingCount)); - } - - public static String shortViewCount(@NonNull final Context context, final long viewCount) { - return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - shortCount(context, viewCount)); - } - - public static String shortSubscriberCount(@NonNull final Context context, - final long subscriberCount) { - return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, - shortCount(context, subscriberCount)); - } - - public static String downloadCount(@NonNull final Context context, final int downloadCount) { - return getQuantity(context, R.plurals.download_finished_notification, 0, - downloadCount, shortCount(context, downloadCount)); - } - - public static String deletedDownloadCount(@NonNull final Context context, - final int deletedCount) { - return getQuantity(context, R.plurals.deleted_downloads_toast, 0, - deletedCount, shortCount(context, deletedCount)); - } - - public static String replyCount(@NonNull final Context context, final int replyCount) { - return getQuantity(context, R.plurals.replies, 0, replyCount, - String.valueOf(replyCount)); - } - - /** - * @param context the Android context - * @param likeCount the like count, possibly negative if unknown - * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise - * the result of calling {@link #shortCount(Context, long)} on the like count - */ - public static String likeCount(@NonNull final Context context, final int likeCount) { - if (likeCount < 0) { - return "-"; - } else { - return shortCount(context, likeCount); - } - } - - /** - * Get a readable text for a duration in the format {@code hours:minutes:seconds}. - * - * @param duration the duration in seconds - * @return a formatted duration String or {@code 00:00} if the duration is zero. - */ - public static String getDurationString(final long duration) { - return DateUtils.formatElapsedTime(Math.max(duration, 0)); - } - - /** - * Get a readable text for a duration in the format {@code hours:minutes:seconds+}. If the given - * duration is incomplete, a plus is appended to the duration string. - * - * @param duration the duration in seconds - * @param isDurationComplete whether the given duration is complete or whether info is missing - * @param showDurationPrefix whether the duration-prefix shall be shown - * @return a formatted duration String or {@code 00:00} if the duration is zero. - */ - public static String getDurationString(final long duration, final boolean isDurationComplete, - final boolean showDurationPrefix) { - final String output = getDurationString(duration); - final String durationPrefix = showDurationPrefix ? "⏱ " : ""; - final String durationPostfix = isDurationComplete ? "" : "+"; - return durationPrefix + output + durationPostfix; - } - - /** - * Localize an amount of seconds into a human readable string. - * - *

The seconds will be converted to the closest whole time unit. - *

For example, 60 seconds would give "1 minute", 119 would also give "1 minute". - * - * @param context used to get plurals resources. - * @param durationInSecs an amount of seconds. - * @return duration in a human readable string. - */ - @NonNull - public static String localizeDuration(@NonNull final Context context, - final int durationInSecs) { - if (durationInSecs < 0) { - throw new IllegalArgumentException("duration can not be negative"); - } - - final int days = (int) (durationInSecs / (24 * 60 * 60L)); - final int hours = (int) (durationInSecs % (24 * 60 * 60L) / (60 * 60L)); - final int minutes = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) / 60L); - final int seconds = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) % 60L); - - final Resources resources = context.getResources(); - - if (days > 0) { - return resources.getQuantityString(R.plurals.days, days, days); - } else if (hours > 0) { - return resources.getQuantityString(R.plurals.hours, hours, hours); - } else if (minutes > 0) { - return resources.getQuantityString(R.plurals.minutes, minutes, minutes); - } else { - return resources.getQuantityString(R.plurals.seconds, seconds, seconds); - } - } - - /** - * Get the localized name of an audio track. - * - *

Examples of results returned by this method:

- *
    - *
  • English (original)
  • - *
  • English (descriptive)
  • - *
  • Spanish (Spain) (dubbed)
  • - *
- * - * @param context the context used to get the app language - * @param track an {@link AudioStream} of the track - * @return the localized name of the audio track - */ - public static String audioTrackName(@NonNull final Context context, final AudioStream track) { - final String name; - if (track.getAudioLocale() != null) { - name = track.getAudioLocale().getDisplayName(); - } else if (track.getAudioTrackName() != null) { - name = track.getAudioTrackName(); - } else { - name = context.getString(R.string.unknown_audio_track); - } - - if (track.getAudioTrackType() != null) { - final String trackType = audioTrackType(context, track.getAudioTrackType()); - return context.getString(R.string.audio_track_name, name, trackType); - } - return name; - } - - @NonNull - private static String audioTrackType(@NonNull final Context context, - @NonNull final AudioTrackType trackType) { - return switch (trackType) { - case ORIGINAL -> context.getString(R.string.audio_track_type_original); - case DUBBED -> context.getString(R.string.audio_track_type_dubbed); - case DESCRIPTIVE -> context.getString(R.string.audio_track_type_descriptive); - case SECONDARY -> context.getString(R.string.audio_track_type_secondary); - }; - } - - /*////////////////////////////////////////////////////////////////////////// - // Pretty Time - //////////////////////////////////////////////////////////////////////////*/ - - public static void initPrettyTime(@NonNull final PrettyTime time) { - prettyTime = time; - // Do not use decades as YouTube doesn't either. - prettyTime.removeUnit(Decade.class); - } - - public static PrettyTime resolvePrettyTime() { - return new PrettyTime(getAppLocale()); - } - - public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) { - return prettyTime.formatUnrounded(offsetDateTime); - } - - /** - * @param context the Android context; if {@code null} then even if in debug mode and the - * setting is enabled, {@code textual} will not be shown next to {@code parsed} - * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if - * the extractor could not parse it - * @param textual the original textual date or time ago string as provided by services - * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise - * {@code textual} is returned. If in debug mode, {@code context != null}, - * {@code parsed != null} and the relevant setting is enabled, {@code textual} will - * be appended to the returned string for debugging purposes. - */ - @Nullable - public static String relativeTimeOrTextual(@Nullable final Context context, - @Nullable final DateWrapper parsed, - @Nullable final String textual) { - if (parsed == null) { - return textual; - } else if (DEBUG && context != null && PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) { - return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")"; - } else { - return relativeTime(parsed.offsetDateTime()); - } - } - - private static Locale getLocaleFromPrefs(@NonNull final Context context, - @StringRes final int prefKey) { - final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - final String defaultKey = context.getString(R.string.default_localization_key); - final String languageCode = sp.getString(context.getString(prefKey), defaultKey); - - if (languageCode.equals(defaultKey)) { - return Locale.getDefault(); - } else { - return Locale.forLanguageTag(languageCode); - } - } - - private static double round(final double value, final int scale) { - return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue(); - } - - /** - * A wrapper around {@code context.getResources().getQuantityString()} with some safeguard. - * - * @param context the Android context - * @param pluralId the ID of the plural resource - * @param zeroCaseStringId the resource ID of the string to use in case {@code count=0}, - * or 0 if the plural resource should be used in the zero case too - * @param count the number that should be used to pick the correct plural form - * @param formattedCount the formatting parameter to substitute inside the plural resource, - * ideally just {@code count} converted to string - * @return the formatted string with the correct pluralization - */ - private static String getQuantity(@NonNull final Context context, - @PluralsRes final int pluralId, - @StringRes final int zeroCaseStringId, - final long count, - final String formattedCount) { - if (count == 0 && zeroCaseStringId != 0) { - return context.getString(zeroCaseStringId); - } - - // As we use the already formatted count - // is not the responsibility of this method handle long numbers - // (it probably will fall in the "other" category, - // or some language have some specific rule... then we have to change it) - final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE); - return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); - } - - // Starting with pull request #12093, NewPipe exclusively uses Android's - // public per-app language APIs to read and set the UI language for NewPipe. - // The following code will migrate any existing custom app language in SharedPreferences to - // use the public per-app language APIs instead. - // For reference, see - // https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html - public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) { - final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - final String appLanguageKey = context.getString(R.string.app_language_key); - final String appLanguageValue = sp.getString(appLanguageKey, null); - if (appLanguageValue != null) { - // The app language key is used on Android versions < 33 - // for more info, see ContentSettingsFragment - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sp.edit().remove(appLanguageKey).apply(); - } - final String appLanguageDefaultValue = - context.getString(R.string.default_localization_key); - if (!appLanguageValue.equals(appLanguageDefaultValue)) { - try { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(appLanguageValue)); - } catch (final RuntimeException e) { - Log.e(TAG, "Failed to migrate previous custom app language " - + "setting to public per-app language APIs" - ); - } - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java deleted file mode 100644 index 9632b9bee..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ /dev/null @@ -1,772 +0,0 @@ -package org.schabi.newpipe.util; - -import static android.text.TextUtils.isEmpty; -import android.text.TextUtils; -import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; - -import com.jakewharton.processphoenix.ProcessPhoenix; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.RouterActivity; -import org.schabi.newpipe.about.AboutActivity; -import org.schabi.newpipe.database.feed.model.FeedGroupEntity; -import org.schabi.newpipe.download.DownloadActivity; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.MainFragment; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.fragments.list.channel.ChannelFragment; -import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; -import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; -import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; -import org.schabi.newpipe.fragments.list.search.SearchFragment; -import org.schabi.newpipe.local.bookmark.BookmarkFragment; -import org.schabi.newpipe.local.feed.FeedFragment; -import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; -import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; -import org.schabi.newpipe.local.subscription.SubscriptionFragment; -import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; -import org.schabi.newpipe.player.PlayQueueActivity; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerIntentType; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.TimestampChangeData; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.settings.SettingsActivity; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.List; -import java.util.Optional; - -public final class NavigationHelper { - public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; - public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; - - private static final String TAG = NavigationHelper.class.getSimpleName(); - - private NavigationHelper() { - } - - /*////////////////////////////////////////////////////////////////////////// - // Players - //////////////////////////////////////////////////////////////////////////*/ - /* INTENT */ - @NonNull - public static Intent getPlayerIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue, - @NonNull final PlayerIntentType playerIntentType) { - final String cacheKey = Optional.ofNullable(playQueue) - .map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class)) - .orElse(null); - return new Intent(context, targetClazz) - .putExtra(Player.PLAY_QUEUE_KEY, cacheKey) - .putExtra(Player.PLAYER_TYPE, PlayerType.MAIN) - .putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) - .putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType); - } - - @NonNull - public static Intent getPlayerTimestampIntent(@NonNull final Context context, - @NonNull final TimestampChangeData data) { - return new Intent(context, PlayerService.class) - .putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange) - .putExtra(Player.PLAYER_INTENT_DATA, data); - } - - @NonNull - public static Intent getPlayerEnqueueNextIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue) { - return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext) - // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false - .putExtra(Player.RESUME_PLAYBACK, false); - } - - /* PLAY */ - public static void playOnMainPlayer(final AppCompatActivity activity, - @NonNull final PlayQueue playQueue) { - final PlayQueueItem item = playQueue.getItem(); - if (item != null) { - openVideoDetailFragment(activity, activity.getSupportFragmentManager(), - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, - false); - } - } - - public static void playOnMainPlayer(final Context context, - @NonNull final PlayQueue playQueue, - final boolean switchingPlayers) { - final PlayQueueItem item = playQueue.getItem(); - if (item != null) { - openVideoDetail(context, - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, - switchingPlayers); - } - } - - public static void playOnPopupPlayer(final Context context, - final PlayQueue queue, - final boolean resumePlayback) { - if (!PermissionHelper.isPopupEnabledElseAsk(context)) { - return; - } - - Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - - final var intent = getPlayerIntent(context, PlayerService.class, queue, - PlayerIntentType.AllOthers) - .putExtra(Player.PLAYER_TYPE, PlayerType.POPUP) - .putExtra(Player.RESUME_PLAYBACK, resumePlayback); - ContextCompat.startForegroundService(context, intent); - } - - public static void playOnBackgroundPlayer(final Context context, - final PlayQueue queue, - final boolean resumePlayback) { - Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) - .show(); - - final Intent intent = getPlayerIntent(context, PlayerService.class, queue, - PlayerIntentType.AllOthers) - .putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO) - .putExtra(Player.RESUME_PLAYBACK, resumePlayback); - ContextCompat.startForegroundService(context, intent); - } - - /* ENQUEUE */ - public static void enqueueOnPlayer(final Context context, - final PlayQueue queue, - final PlayerType playerType) { - if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) { - return; - } - - Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); - - // when enqueueing `resumePlayback` is always `false` since: - // - if there is a video already playing, the value of `resumePlayback` just doesn't make - // any difference. - // - if there is nothing already playing, it is useful for the enqueue action to have a - // slightly different behaviour than the normal play action: the latter resumes playback, - // the former doesn't. (note that enqueue can be triggered when nothing is playing only - // by long pressing the video detail fragment, playlist or channel controls - final Intent intent = getPlayerIntent(context, PlayerService.class, queue, - PlayerIntentType.Enqueue) - .putExtra(Player.RESUME_PLAYBACK, false) - .putExtra(Player.PLAYER_TYPE, playerType); - ContextCompat.startForegroundService(context, intent); - } - - public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); - if (playerType == null) { - Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); - playerType = PlayerType.AUDIO; - } - - enqueueOnPlayer(context, queue, playerType); - } - - /* ENQUEUE NEXT */ - public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); - if (playerType == null) { - Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); - playerType = PlayerType.AUDIO; - } - Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue) - .putExtra(Player.PLAYER_TYPE, playerType); - ContextCompat.startForegroundService(context, intent); - } - - /*////////////////////////////////////////////////////////////////////////// - // External Players - //////////////////////////////////////////////////////////////////////////*/ - - public static void playOnExternalAudioPlayer(@NonNull final Context context, - @NonNull final StreamInfo info) { - final List audioStreams = info.getAudioStreams(); - if (audioStreams == null || audioStreams.isEmpty()) { - Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); - return; - } - - final List audioStreamsForExternalPlayers = - getUrlAndNonTorrentStreams(audioStreams); - if (audioStreamsForExternalPlayers.isEmpty()) { - Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, - Toast.LENGTH_SHORT).show(); - return; - } - - final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers); - final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); - - playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); - } - - public static void playOnExternalVideoPlayer(final Context context, - @NonNull final StreamInfo info) { - final List videoStreams = info.getVideoStreams(); - if (videoStreams == null || videoStreams.isEmpty()) { - Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); - return; - } - - final List videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList(context, - getUrlAndNonTorrentStreams(videoStreams), null, false, false); - if (videoStreamsForExternalPlayers.isEmpty()) { - Toast.makeText(context, R.string.no_video_streams_available_for_external_players, - Toast.LENGTH_SHORT).show(); - return; - } - - final int index = ListHelper.getDefaultResolutionIndex(context, - videoStreamsForExternalPlayers); - - final VideoStream videoStream = videoStreamsForExternalPlayers.get(index); - playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); - } - - public static void playOnExternalPlayer(@NonNull final Context context, - @Nullable final String name, - @Nullable final String artist, - @NonNull final Stream stream) { - final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); - final String mimeType; - - if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { - Toast.makeText(context, R.string.selected_stream_external_player_not_supported, - Toast.LENGTH_SHORT).show(); - return; - } - - switch (deliveryMethod) { - case PROGRESSIVE_HTTP: - if (stream.getFormat() == null) { - if (stream instanceof AudioStream) { - mimeType = "audio/*"; - } else if (stream instanceof VideoStream) { - mimeType = "video/*"; - } else { - // This should never be reached, because subtitles are not opened in - // external players - return; - } - } else { - mimeType = stream.getFormat().getMimeType(); - } - break; - case HLS: - mimeType = "application/x-mpegURL"; - break; - case DASH: - mimeType = "application/dash+xml"; - break; - case SS: - mimeType = "application/vnd.ms-sstr+xml"; - break; - default: - // Torrent streams are not exposed to external players - mimeType = ""; - } - - final Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.parse(stream.getContent()), mimeType); - intent.putExtra(Intent.EXTRA_TITLE, name); - intent.putExtra("title", name); - intent.putExtra("artist", artist); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - resolveActivityOrAskToInstall(context, intent); - } - - public static void resolveActivityOrAskToInstall(@NonNull final Context context, - @NonNull final Intent intent) { - if (!ShareUtils.tryOpenIntentInApp(context, intent)) { - if (context instanceof Activity) { - new AlertDialog.Builder(context) - .setMessage(R.string.no_player_found) - .setPositiveButton(R.string.install, (dialog, which) -> - ShareUtils.installApp(context, - context.getString(R.string.vlc_package))) - .setNegativeButton(R.string.cancel, (dialog, which) -> - Log.i("NavigationHelper", "You unlocked a secret unicorn.")) - .show(); - } else { - Toast.makeText(context, R.string.no_player_found_toast, Toast.LENGTH_LONG).show(); - } - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Through FragmentManager - //////////////////////////////////////////////////////////////////////////*/ - - @SuppressLint("CommitTransaction") - private static FragmentTransaction defaultTransaction(final FragmentManager fragmentManager) { - return fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, - R.animator.custom_fade_in, R.animator.custom_fade_out); - } - - public static void gotoMainFragment(final FragmentManager fragmentManager) { - final boolean popped = fragmentManager.popBackStackImmediate(MAIN_FRAGMENT_TAG, 0); - if (!popped) { - openMainFragment(fragmentManager); - } - } - - public static void openMainFragment(final FragmentManager fragmentManager) { - InfoCache.getInstance().trimCache(); - - fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new MainFragment()) - .addToBackStack(MAIN_FRAGMENT_TAG) - .commit(); - } - - public static boolean tryGotoSearchFragment(final FragmentManager fragmentManager) { - if (MainActivity.DEBUG) { - for (int i = 0; i < fragmentManager.getBackStackEntryCount(); i++) { - Log.d("NavigationHelper", "tryGoToSearchFragment() [" + i + "]" - + " = [" + fragmentManager.getBackStackEntryAt(i) + "]"); - } - } - - return fragmentManager.popBackStackImmediate(SEARCH_FRAGMENT_TAG, 0); - } - - public static void openSearchFragment(final FragmentManager fragmentManager, - final int serviceId, final String searchString) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, searchString)) - .addToBackStack(SEARCH_FRAGMENT_TAG) - .commit(); - } - - public static void expandMainPlayer(final Context context) { - context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER)); - } - - public static void sendPlayerStartedEvent(final Context context) { - context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_PLAYER_STARTED)); - } - - public static void showMiniPlayer(final FragmentManager fragmentManager) { - final VideoDetailFragment instance = VideoDetailFragment.getInstanceInCollapsedState(); - defaultTransaction(fragmentManager) - .replace(R.id.fragment_player_holder, instance) - .runOnCommit(() -> sendPlayerStartedEvent(instance.requireActivity())) - .commitAllowingStateLoss(); - } - - private interface RunnableWithVideoDetailFragment { - void run(VideoDetailFragment detailFragment); - } - - public static void openVideoDetailFragment(@NonNull final Context context, - @NonNull final FragmentManager fragmentManager, - final int serviceId, - @Nullable final String url, - @NonNull final String title, - @Nullable final PlayQueue playQueue, - final boolean switchingPlayers) { - - final boolean autoPlay; - @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType(); - if (playerType == null) { - // no player open - autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); - } else if (switchingPlayers) { - // switching player to main player - autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state - } else if (playerType == PlayerType.MAIN) { - // opening new stream while already playing in main player - autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); - } else { - // opening new stream while already playing in another player - autoPlay = false; - } - - final RunnableWithVideoDetailFragment onVideoDetailFragmentReady = detailFragment -> { - expandMainPlayer(detailFragment.requireActivity()); - detailFragment.setAutoPlay(autoPlay); - if (switchingPlayers && TextUtils.equals(detailFragment.getUrl(), url)) { - // Situation when user switches from players to main player. All needed data is - // here, we can start watching (assuming newQueue equals playQueue). - // Starting directly in fullscreen if the previous player type was popup. - detailFragment.openVideoPlayer(playerType == PlayerType.POPUP - || PlayerHelper.isStartMainPlayerFullscreenEnabled(context)); - } else { - if (switchingPlayers && playerType == PlayerType.POPUP) { - detailFragment.setForceFullscreen(true); - } - detailFragment.selectAndLoadVideo(serviceId, url, title, playQueue); - } - detailFragment.scrollToTop(); - }; - - final Fragment fragment = fragmentManager.findFragmentById(R.id.fragment_player_holder); - if (fragment instanceof VideoDetailFragment && fragment.isVisible()) { - onVideoDetailFragmentReady.run((VideoDetailFragment) fragment); - } else { - // Specify no url here, otherwise the VideoDetailFragment will start loading the - // stream automatically if it's the first time it is being opened, but then - // onVideoDetailFragmentReady will kick in and start another loading process. - // See VideoDetailFragment.wasCleared() and its usage in doInitialLoadLogic(). - final VideoDetailFragment instance = VideoDetailFragment - .getInstance(serviceId, null, title, playQueue); - instance.setAutoPlay(autoPlay); - - defaultTransaction(fragmentManager) - .replace(R.id.fragment_player_holder, instance) - .runOnCommit(() -> onVideoDetailFragmentReady.run(instance)) - .commit(); - } - } - - public static void openChannelFragment(final FragmentManager fragmentManager, - final int serviceId, final String url, - @NonNull final String name) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, name)) - .addToBackStack(null) - .commit(); - } - - public static void openChannelFragment(@NonNull final Fragment fragment, - @NonNull final StreamInfoItem item, - final String uploaderUrl) { - // For some reason `getParentFragmentManager()` doesn't work, but this does. - openChannelFragment( - fragment.requireActivity().getSupportFragmentManager(), - item.getServiceId(), uploaderUrl, item.getUploaderName()); - } - - /** - * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()} - * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong. - * - * @param activity the activity with the fragment manager and in which to show the snackbar - * @param comment the comment whose uploader/author will be opened - */ - public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity, - @NonNull final CommentsInfoItem comment) { - if (isEmpty(comment.getUploaderUrl())) { - return; - } - try { - openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), - comment.getUploaderUrl(), comment.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); - } - } - - public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity, - @NonNull final CommentsInfoItem comment) { - closeCommentRepliesFragments(activity); - defaultTransaction(activity.getSupportFragmentManager()) - .replace(R.id.fragment_holder, new CommentRepliesFragment(comment), - CommentRepliesFragment.TAG) - .addToBackStack(CommentRepliesFragment.TAG) - .commit(); - } - - /** - * Closes all open {@link CommentRepliesFragment}s in {@code activity}, - * including those that are not at the top of the back stack. - * This is needed to prevent multiple open CommentRepliesFragments - * Ideally there should only be one since we remove existing before opening a new one. - * @param activity the activity in which to close the CommentRepliesFragments - */ - public static void closeCommentRepliesFragments(@NonNull final FragmentActivity activity) { - final FragmentManager fm = activity.getSupportFragmentManager(); - - // Remove all existing fragment instances tagged as CommentRepliesFragment - final FragmentTransaction tx = defaultTransaction(fm); - boolean removed = false; - for (final Fragment fragment : fm.getFragments()) { - if (fragment != null && CommentRepliesFragment.TAG.equals(fragment.getTag())) { - tx.remove(fragment); - removed = true; - } - } - if (removed) { - tx.commit(); - } - - // Only pop back stack entries named CommentRepliesFragment.TAG if they are at the top. - while (fm.getBackStackEntryCount() > 0 - && CommentRepliesFragment.TAG.equals( - fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 1).getName() - ) - ) { - fm.popBackStackImmediate(CommentRepliesFragment.TAG, - FragmentManager.POP_BACK_STACK_INCLUSIVE); - } - - } - - public static void openPlaylistFragment(final FragmentManager fragmentManager, - final int serviceId, final String url, - @NonNull final String name) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) - .addToBackStack(null) - .commit(); - } - - public static void openFeedFragment(final FragmentManager fragmentManager) { - openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null); - } - - public static void openFeedFragment(final FragmentManager fragmentManager, final long groupId, - @Nullable final String groupName) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) - .addToBackStack(null) - .commit(); - } - - public static void openBookmarksFragment(final FragmentManager fragmentManager) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new BookmarkFragment()) - .addToBackStack(null) - .commit(); - } - - public static void openSubscriptionFragment(final FragmentManager fragmentManager) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new SubscriptionFragment()) - .addToBackStack(null) - .commit(); - } - - public static void openKioskFragment(final FragmentManager fragmentManager, final int serviceId, - final String kioskId) throws ExtractionException { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId)) - .addToBackStack(null) - .commit(); - } - - public static void openLocalPlaylistFragment(final FragmentManager fragmentManager, - final long playlistId, final String name) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, - name == null ? "" : name)) - .addToBackStack(null) - .commit(); - } - - public static void openStatisticFragment(final FragmentManager fragmentManager) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new StatisticsPlaylistFragment()) - .addToBackStack(null) - .commit(); - } - - public static void openSubscriptionsImportFragment(final FragmentManager fragmentManager, - final int serviceId) { - defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId)) - .addToBackStack(null) - .commit(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Through Intents - //////////////////////////////////////////////////////////////////////////*/ - - public static void openSearch(final Context context, final int serviceId, - final String searchString) { - final Intent mIntent = new Intent(context, MainActivity.class); - mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); - mIntent.putExtra(Constants.KEY_SEARCH_STRING, searchString); - mIntent.putExtra(Constants.KEY_OPEN_SEARCH, true); - context.startActivity(mIntent); - } - - public static void openVideoDetail(final Context context, - final int serviceId, - final String url, - @NonNull final String title, - @Nullable final PlayQueue playQueue, - final boolean switchingPlayers) { - - final Intent intent = getStreamIntent(context, serviceId, url, title) - .putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers); - - if (playQueue != null) { - final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); - if (cacheKey != null) { - intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); - } - } - context.startActivity(intent); - } - - /** - * Opens {@link ChannelFragment}. - * Use this instead of {@link #openChannelFragment(FragmentManager, int, String, String)} - * when no fragments are used / no FragmentManager is available. - * @param context - * @param serviceId - * @param url - * @param title - */ - public static void openChannelFragmentUsingIntent(final Context context, - final int serviceId, - final String url, - @NonNull final String title) { - final Intent intent = getOpenIntent(context, url, serviceId, - StreamingService.LinkType.CHANNEL); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.putExtra(Constants.KEY_TITLE, title); - - context.startActivity(intent); - } - - public static void openMainActivity(final Context context) { - final Intent mIntent = new Intent(context, MainActivity.class); - mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); - context.startActivity(mIntent); - } - - public static void openRouterActivity(final Context context, final String url) { - final Intent mIntent = new Intent(context, RouterActivity.class); - mIntent.setData(Uri.parse(url)); - context.startActivity(mIntent); - } - - public static void openAbout(final Context context) { - final Intent intent = new Intent(context, AboutActivity.class); - context.startActivity(intent); - } - - public static void openSettings(final Context context) { - final Intent intent = new Intent(context, SettingsActivity.class); - context.startActivity(intent); - } - - public static void openDownloads(final Activity activity) { - if (PermissionHelper.checkStoragePermissions( - activity, PermissionHelper.DOWNLOADS_REQUEST_CODE)) { - final Intent intent = new Intent(activity, DownloadActivity.class); - activity.startActivity(intent); - } - } - - public static Intent getPlayQueueActivityIntent(final Context context) { - final Intent intent = new Intent(context, PlayQueueActivity.class); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - } - return intent; - } - - public static void openPlayQueue(final Context context) { - final Intent intent = new Intent(context, PlayQueueActivity.class); - context.startActivity(intent); - } - - /*////////////////////////////////////////////////////////////////////////// - // Link handling - //////////////////////////////////////////////////////////////////////////*/ - - private static Intent getOpenIntent(final Context context, final String url, - final int serviceId, final StreamingService.LinkType type) { - final Intent mIntent = new Intent(context, MainActivity.class); - mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); - mIntent.putExtra(Constants.KEY_URL, url); - mIntent.putExtra(Constants.KEY_LINK_TYPE, type); - return mIntent; - } - - public static Intent getIntentByLink(final Context context, final String url) - throws ExtractionException { - return getIntentByLink(context, NewPipe.getServiceByUrl(url), url); - } - - public static Intent getIntentByLink(final Context context, - final StreamingService service, - final String url) throws ExtractionException { - final StreamingService.LinkType linkType = service.getLinkTypeByUrl(url); - - if (linkType == StreamingService.LinkType.NONE) { - throw new ExtractionException("Url not known to service. service=" + service - + " url=" + url); - } - - return getOpenIntent(context, url, service.getServiceId(), linkType); - } - - public static Intent getChannelIntent(final Context context, - final int serviceId, - final String url) { - return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL); - } - - public static Intent getStreamIntent(final Context context, - final int serviceId, - final String url, - @Nullable final String title) { - return getOpenIntent(context, url, serviceId, StreamingService.LinkType.STREAM) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(Constants.KEY_TITLE, title); - } - - /** - * Finish this Activity as well as all Activities running below it - * and then start MainActivity. - * - * @param activity the activity to finish - */ - public static void restartApp(final Activity activity) { - NewPipeDatabase.close(); - - ProcessPhoenix.triggerRebirth(activity.getApplicationContext()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt b/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt deleted file mode 100644 index 159791813..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.text.Selection -import android.text.Spannable -import android.widget.TextView -import org.schabi.newpipe.util.external_communication.ShareUtils - -object NewPipeTextViewHelper { - /** - * Share the selected text of [NewPipeTextViews][org.schabi.newpipe.views.NewPipeTextView] and - * [NewPipeEditTexts][org.schabi.newpipe.views.NewPipeEditText] with - * [ShareUtils.shareText]. - * - * - * - * This allows EMUI users to get the Android share sheet instead of the EMUI share sheet when - * using the `Share` command of the popup menu which appears when selecting text. - * - * - * @param textView the [TextView] on which sharing the selected text. It should be a - * [org.schabi.newpipe.views.NewPipeTextView] or a [org.schabi.newpipe.views.NewPipeEditText] - * (even if [standard TextViews][TextView] are supported). - */ - @JvmStatic - fun shareSelectedTextWithShareUtils(textView: TextView) { - val textViewText = textView.getText() - shareSelectedTextIfNotNullAndNotEmpty(textView, getSelectedText(textView, textViewText)) - if (textViewText is Spannable) { - Selection.setSelection(textViewText, textView.selectionEnd) - } - } - - private fun getSelectedText(textView: TextView, text: CharSequence?): CharSequence? { - if (!textView.hasSelection() || text == null) { - return null - } - - val start = textView.selectionStart - val end = textView.selectionEnd - return if (start > end) { - text.subSequence(end, start) - } else { - text.subSequence(start, end) - } - } - - private fun shareSelectedTextIfNotNullAndNotEmpty( - textView: TextView, - selectedText: CharSequence? - ) { - if (!selectedText.isNullOrEmpty()) { - ShareUtils.shareText(textView.context, "", selectedText.toString()) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java deleted file mode 100644 index ae8d86af1..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.schabi.newpipe.util; - -import androidx.recyclerview.widget.RecyclerView; - -public interface OnClickGesture { - void selected(T selectedItem); - - default void held(final T selectedItem) { - // Optional gesture - } - - default void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) { - // Optional gesture - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt deleted file mode 100644 index 9cf3c1e73..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2019-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import com.grack.nanojson.JsonObject -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonWriter -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance - -object PeertubeHelper { - - @JvmStatic - val currentInstance: PeertubeInstance - get() = ServiceList.PeerTube.instance - - @JvmStatic - fun getInstanceList(context: Context): List { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val savedInstanceListKey = context.getString(R.string.peertube_instance_list_key) - val savedJson = sharedPreferences.getString(savedInstanceListKey, null) - ?: return listOf(currentInstance) - - return runCatching { - JsonParser.`object`().from(savedJson).getArray("instances") - .filterIsInstance() - .map { PeertubeInstance(it.getString("url"), it.getString("name")) } - }.getOrDefault(listOf(currentInstance)) - } - - @JvmStatic - fun selectInstance(instance: PeertubeInstance, context: Context): PeertubeInstance { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val selectedInstanceKey = context.getString(R.string.peertube_selected_instance_key) - - val jsonWriter = JsonWriter.string().`object`() - jsonWriter.value("name", instance.name) - jsonWriter.value("url", instance.url) - val jsonToSave = jsonWriter.end().done() - - sharedPreferences.edit { putString(selectedInstanceKey, jsonToSave) } - ServiceList.PeerTube.instance = instance - return instance - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java deleted file mode 100644 index 77dc472f7..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ /dev/null @@ -1,182 +0,0 @@ -package org.schabi.newpipe.util; - -import android.Manifest; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.provider.Settings; -import android.text.Html; -import android.widget.Toast; - -import androidx.appcompat.app.AlertDialog; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.settings.NewPipeSettings; - -public final class PermissionHelper { - public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779; - public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; - public static final int DOWNLOADS_REQUEST_CODE = 777; - - private PermissionHelper() { } - - public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { - if (NewPipeSettings.useStorageAccessFramework(activity)) { - return true; // Storage permissions are not needed for SAF - } - - if (!checkReadStoragePermissions(activity, requestCode)) { - return false; - } - return checkWriteStoragePermissions(activity, requestCode); - } - - public static boolean checkReadStoragePermissions(final Activity activity, - final int requestCode) { - if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(activity, - new String[]{ - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE}, - requestCode); - - return false; - } - return true; - } - - - public static boolean checkWriteStoragePermissions(final Activity activity, - final int requestCode) { - // Here, thisActivity is the current activity - if (ContextCompat.checkSelfPermission(activity, - Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - - // Should we show an explanation? - /*if (ActivityCompat.shouldShowRequestPermissionRationale(activity, - Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - - // Show an explanation to the user *asynchronously* -- don't block - // this thread waiting for the user's response! After the user - // sees the explanation, try again to request the permission. - } else {*/ - - // No explanation needed, we can request the permission. - ActivityCompat.requestPermissions(activity, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); - - // PERMISSION_WRITE_STORAGE is an - // app-defined int constant. The callback method gets the - // result of the request. - /*}*/ - return false; - } - return true; - } - - public static boolean checkPostNotificationsPermission(final Activity activity, - final int requestCode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(activity, - Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED) { - if (!App.getInstance().getNotificationsRequested()) { - ActivityCompat.requestPermissions(activity, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, requestCode); - App.getInstance().setNotificationsRequested(); - return false; - } - } - return true; - } - - /** - * In order to be able to draw over other apps, - * the permission android.permission.SYSTEM_ALERT_WINDOW have to be granted. - *

- * On < API 23 (MarshMallow) the permission was granted - * when the user installed the application (via AndroidManifest), - * on > 23, however, it have to start a activity asking the user if he agrees. - *

- *

- * This method just return if the app has permission to draw over other apps, - * and if it doesn't, it will try to get the permission. - *

- * - * @param context {@link Context} - * @return {@link Settings#canDrawOverlays(Context)} - **/ - public static boolean checkSystemAlertWindowPermission(final Context context) { - if (!Settings.canDrawOverlays(context)) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:" + context.getPackageName())); - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try { - context.startActivity(i); - } catch (final ActivityNotFoundException ignored) { - } - return false; - // from Android R the ACTION_MANAGE_OVERLAY_PERMISSION will only point to the menu, - // so let’s add a dialog that points the user to the right setting. - } else { - final String appName = context.getApplicationInfo() - .loadLabel(context.getPackageManager()).toString(); - final String title = context.getString(R.string.permission_display_over_apps); - final String permissionName = - context.getString(R.string.permission_display_over_apps_permission_name); - final String appNameItalic = "" + appName + ""; - final String permissionNameItalic = "" + permissionName + ""; - final String message = - context.getString(R.string.permission_display_over_apps_message, - appNameItalic, - permissionNameItalic - ); - new AlertDialog.Builder(context) - .setTitle(title) - .setMessage(Html.fromHtml(message, Html.FROM_HTML_MODE_COMPACT)) - .setPositiveButton("OK", (dialog, which) -> { - // we don’t need the package name here, since it won’t do anything on >R - final Intent intent = - new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); - try { - context.startActivity(intent); - } catch (final ActivityNotFoundException ignored) { - } - }) - .setCancelable(true) - .show(); - return false; - } - - } else { - return true; - } - } - - /** - * Determines whether the popup is enabled, and if it is not, starts the system activity to - * request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a - * toast to the user explaining why the permission is needed. - * - * @param context the Android context - * @return whether the popup is enabled - */ - public static boolean isPopupEnabledElseAsk(final Context context) { - if (checkSystemAlertWindowPermission(context)) { - return true; - } else { - Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show(); - return false; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt deleted file mode 100644 index 40734ec58..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import android.view.View -import android.view.View.OnLongClickListener -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.PreferenceManager -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.PlaylistControlBinding -import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder -import org.schabi.newpipe.player.PlayerType - -/** - * Utility class for play buttons and their respective click listeners. - */ -object PlayButtonHelper { - /** - * Initialize [OnClickListener][View.OnClickListener] - * and [OnLongClickListener][OnLongClickListener] for playlist control - * buttons defined in [R.layout.playlist_control]. - * - * @param activity The activity to use for the [Toast][Toast]. - * @param playlistControlBinding The binding of the - * [playlist control layout][R.layout.playlist_control]. - * @param fragment The fragment to get the play queue from. - */ - @JvmStatic - fun initPlaylistControlClickListener( - activity: AppCompatActivity, - playlistControlBinding: PlaylistControlBinding, - fragment: PlaylistControlViewHolder - ) { - // click listener - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener { - NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()) - showHoldToAppendToastIfNeeded(activity) - } - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener { - NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false) - showHoldToAppendToastIfNeeded(activity) - } - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener { - NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false) - showHoldToAppendToastIfNeeded(activity) - } - - // long click listener - playlistControlBinding.playlistCtrlPlayAllButton.setOnLongClickListener { - NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.MAIN) - true - } - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener { - NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP) - true - } - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener { - NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO) - true - } - } - - /** - * Show the "hold to append" toast if the corresponding preference is enabled. - * - * @param context The context to show the toast. - */ - private fun showHoldToAppendToastIfNeeded(context: Context) { - if (shouldShowHoldToAppendTip(context)) { - Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show() - } - } - - /** - * Check if the "hold to append" toast should be shown. - * - * - * - * The tip is shown if the corresponding preference is enabled. - * This is the default behaviour. - * - * - * @param context The context to get the preference. - * @return `true` if the tip should be shown, `false` otherwise. - */ - @JvmStatic - fun shouldShowHoldToAppendTip(context: Context): Boolean { - return PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.show_hold_to_append_key), true) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt deleted file mode 100644 index 31d42d751..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.util - -import android.content.pm.PackageManager -import androidx.core.content.pm.PackageInfoCompat -import java.time.Instant -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import org.schabi.newpipe.App -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification -import org.schabi.newpipe.error.UserAction - -object ReleaseVersionUtil { - // Public key of the certificate that is used in NewPipe release versions - private const val RELEASE_CERT_PUBLIC_KEY_SHA256 = - "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab" - - @OptIn(ExperimentalStdlibApi::class) - val isReleaseApk by lazy { - @Suppress("NewApi") - val certificates = mapOf( - RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256 - ) - val app = App.instance - try { - PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false) - } catch (e: PackageManager.NameNotFoundException) { - createNotification( - app, - ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info") - ) - false - } - } - - fun isLastUpdateCheckExpired(expiry: Long): Boolean { - return Instant.ofEpochSecond(expiry) < Instant.now() - } - - /** - * Coerce expiry date time in between 6 hours and 72 hours from now - * - * @return Epoch second of expiry date time - */ - fun coerceUpdateCheckExpiry(expiryString: String?): Long { - val nowPlus6Hours = ZonedDateTime.now().plusHours(6) - val expiry = expiryString?.let { - ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(it)) - .coerceIn(nowPlus6Hours, nowPlus6Hours.plusHours(66)) - } ?: nowPlus6Hours - return expiry.toEpochSecond() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SavedState.kt b/app/src/main/java/org/schabi/newpipe/util/SavedState.kt deleted file mode 100644 index c556b59ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SavedState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.schabi.newpipe.util - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -/** - * Information about the saved state on the disk. - */ -@Parcelize -class SavedState( - /** - * Get the prefix of the saved file. - * - * @return the file prefix - */ - val prefixFileSaved: String, - /** - * Get the path to the saved file. - * - * @return the path to the saved file - */ - val pathFileSaved: String -) : Parcelable { - override fun toString() = "$prefixFileSaved > $pathFileSaved" -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java deleted file mode 100644 index 69dc697fe..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ /dev/null @@ -1,75 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; - -import java.util.List; - -public class SecondaryStreamHelper { - private final int position; - private final StreamInfoWrapper streams; - - public SecondaryStreamHelper(@NonNull final StreamInfoWrapper streams, - final T selectedStream) { - this.streams = streams; - this.position = streams.getStreamsList().indexOf(selectedStream); - if (this.position < 0) { - throw new RuntimeException("selected stream not found"); - } - } - - /** - * Finds an audio stream compatible with the provided video-only stream, so that the two streams - * can be combined in a single file by the downloader. If there are multiple available audio - * streams, chooses either the highest or the lowest quality one based on - * {@link ListHelper#isLimitingDataUsage(Context)}. - * - * @param context Android context - * @param audioStreams list of audio streams - * @param videoStream desired video-ONLY stream - * @return the selected audio stream or null if a candidate was not found - */ - @Nullable - public static AudioStream getAudioStreamFor(@NonNull final Context context, - @NonNull final List audioStreams, - @NonNull final VideoStream videoStream) { - final MediaFormat mediaFormat = videoStream.getFormat(); - - if (mediaFormat == MediaFormat.WEBM) { - return audioStreams - .stream() - .filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA - || audioStream.getFormat() == MediaFormat.WEBMA_OPUS) - .max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, - ListHelper.isLimitingDataUsage(context))) - .orElse(null); - - } else if (mediaFormat == MediaFormat.MPEG_4) { - return audioStreams - .stream() - .filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A) - .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A, - ListHelper.isLimitingDataUsage(context))) - .orElse(null); - - } else { - return null; - } - } - - public T getStream() { - return streams.getStreamsList().get(position); - } - - public long getSizeInBytes() { - return streams.getSizeInBytes(position); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java deleted file mode 100644 index b4c196ce4..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.schabi.newpipe.util; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.LruCache; - -import org.schabi.newpipe.MainActivity; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.Serializable; -import java.util.UUID; - -public final class SerializedCache { - private static final boolean DEBUG = MainActivity.DEBUG; - private static final SerializedCache INSTANCE = new SerializedCache(); - private static final int MAX_ITEMS_ON_CACHE = 5; - private static final LruCache> LRU_CACHE = - new LruCache<>(MAX_ITEMS_ON_CACHE); - private static final String TAG = "SerializedCache"; - - private SerializedCache() { - //no instance - } - - public static SerializedCache getInstance() { - return INSTANCE; - } - - @Nullable - public T take(@NonNull final String key, @NonNull final Class type) { - if (DEBUG) { - Log.d(TAG, "take() called with: key = [" + key + "]"); - } - synchronized (LRU_CACHE) { - return LRU_CACHE.get(key) != null ? getItem(LRU_CACHE.remove(key), type) : null; - } - } - - @Nullable - public T get(@NonNull final String key, @NonNull final Class type) { - if (DEBUG) { - Log.d(TAG, "get() called with: key = [" + key + "]"); - } - synchronized (LRU_CACHE) { - final CacheData data = LRU_CACHE.get(key); - return data != null ? getItem(data, type) : null; - } - } - - @Nullable - public String put(@NonNull final T item, - @NonNull final Class type) { - final String key = UUID.randomUUID().toString(); - return put(key, item, type) ? key : null; - } - - public boolean put(@NonNull final String key, @NonNull final T item, - @NonNull final Class type) { - if (DEBUG) { - Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]"); - } - synchronized (LRU_CACHE) { - try { - LRU_CACHE.put(key, new CacheData<>(clone(item, type), type)); - return true; - } catch (final Exception error) { - Log.e(TAG, "Serialization failed for: ", error); - } - } - return false; - } - - public void clear() { - if (DEBUG) { - Log.d(TAG, "clear() called"); - } - synchronized (LRU_CACHE) { - LRU_CACHE.evictAll(); - } - } - - public long size() { - synchronized (LRU_CACHE) { - return LRU_CACHE.size(); - } - } - - @Nullable - private T getItem(@NonNull final CacheData data, @NonNull final Class type) { - return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; - } - - @NonNull - private T clone(@NonNull final T item, - @NonNull final Class type) throws Exception { - final ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream(); - try (ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput)) { - objectOutput.writeObject(item); - objectOutput.flush(); - } - final Object clone = new ObjectInputStream( - new ByteArrayInputStream(bytesOutput.toByteArray())).readObject(); - return type.cast(clone); - } - - private static final class CacheData { - private final T item; - private final Class type; - - private CacheData(@NonNull final T item, @NonNull final Class type) { - this.item = item; - this.type = type; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt deleted file mode 100644 index 4239f43e0..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import android.content.Context -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import com.grack.nanojson.JsonParser -import java.util.concurrent.TimeUnit -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.StreamingService -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance -import org.schabi.newpipe.ktx.getStringSafe - -object ServiceHelper { - private val DEFAULT_FALLBACK_SERVICE: StreamingService = ServiceList.YouTube - - @JvmStatic - @DrawableRes - fun getIcon(serviceId: Int): Int { - return when (serviceId) { - 0 -> R.drawable.ic_smart_display - 1 -> R.drawable.ic_cloud - 2 -> R.drawable.ic_placeholder_media_ccc - 3 -> R.drawable.ic_placeholder_peertube - 4 -> R.drawable.ic_placeholder_bandcamp - else -> R.drawable.ic_circle - } - } - - @JvmStatic - fun getTranslatedFilterString(filter: String, context: Context): String { - return when (filter) { - "all" -> context.getString(R.string.all) - "videos", "sepia_videos", "music_videos" -> context.getString(R.string.videos_string) - "channels" -> context.getString(R.string.channels) - "playlists", "music_playlists" -> context.getString(R.string.playlists) - "tracks" -> context.getString(R.string.tracks) - "users" -> context.getString(R.string.users) - "conferences" -> context.getString(R.string.conferences) - "events" -> context.getString(R.string.events) - "music_songs" -> context.getString(R.string.songs) - "music_albums" -> context.getString(R.string.albums) - "music_artists" -> context.getString(R.string.artists) - else -> filter - } - } - - /** - * Get a resource string with instructions for importing subscriptions for each service. - * - * @param serviceId service to get the instructions for - * @return the string resource containing the instructions or -1 if the service don't support it - */ - @JvmStatic - @StringRes - fun getImportInstructions(serviceId: Int): Int { - return when (serviceId) { - 0 -> R.string.import_youtube_instructions - 1 -> R.string.import_soundcloud_instructions - else -> -1 - } - } - - /** - * For services that support importing from a channel url, return a hint that will - * be used in the EditText that the user will type in his channel url. - * - * @param serviceId service to get the hint for - * @return the hint's string resource or -1 if the service don't support it - */ - @JvmStatic - @StringRes - fun getImportInstructionsHint(serviceId: Int): Int { - return when (serviceId) { - 1 -> R.string.import_soundcloud_instructions_hint - else -> -1 - } - } - - @JvmStatic - fun getSelectedServiceId(context: Context): Int { - return (getSelectedService(context) ?: DEFAULT_FALLBACK_SERVICE).serviceId - } - - @JvmStatic - fun getSelectedService(context: Context): StreamingService? { - val serviceName: String = PreferenceManager.getDefaultSharedPreferences(context) - .getStringSafe( - context.getString(R.string.current_service_key), - context.getString(R.string.default_service_value) - ) - - return runCatching { NewPipe.getService(serviceName) }.getOrNull() - } - - @JvmStatic - fun getNameOfServiceById(serviceId: Int): String { - return ServiceList.all().stream() - .filter { it.serviceId == serviceId } - .findFirst() - .map(StreamingService::getServiceInfo) - .map(StreamingService.ServiceInfo::getName) - .orElse("") - } - - /** - * @param serviceId the id of the service - * @return the service corresponding to the provided id - * @throws java.util.NoSuchElementException if there is no service with the provided id - */ - @JvmStatic - fun getServiceById(serviceId: Int): StreamingService { - return ServiceList.all().firstNotNullOf { it.takeIf { it.serviceId == serviceId } } - } - - @JvmStatic - fun setSelectedServiceId(context: Context, serviceId: Int) { - val serviceName = runCatching { NewPipe.getService(serviceId).serviceInfo.name } - .getOrDefault(DEFAULT_FALLBACK_SERVICE.serviceInfo.name) - - setSelectedServicePreferences(context, serviceName) - } - - private fun setSelectedServicePreferences(context: Context, serviceName: String?) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - sharedPreferences.edit { putString(context.getString(R.string.current_service_key), serviceName) } - } - - @JvmStatic - fun getCacheExpirationMillis(serviceId: Int): Long { - return if (serviceId == ServiceList.SoundCloud.serviceId) { - TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES) - } else { - TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS) - } - } - - fun initService(context: Context, serviceId: Int) { - if (serviceId == ServiceList.PeerTube.serviceId) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val json = sharedPreferences.getString( - context.getString(R.string.peertube_selected_instance_key), - null - ) ?: return - - val jsonObject = runCatching { JsonParser.`object`().from(json) } - .getOrElse { return@initService } - - ServiceList.PeerTube.instance = PeertubeInstance( - jsonObject.getString("url"), - jsonObject.getString("name") - ) - } - } - - @JvmStatic - fun initServices(context: Context) { - ServiceList.all().forEach { initService(context, it.serviceId) } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt b/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt deleted file mode 100644 index a79085fc0..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.schabi.newpipe.util - -import android.widget.SeekBar - -/** - * Why the hell didn't they make a stub implementation for this? - */ -abstract class SimpleOnSeekBarChangeListener : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} - override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) {} -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java deleted file mode 100644 index c6191fcc2..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.schabi.newpipe.util; - -public interface SliderStrategy { - /** - * Converts from zeroed double with a minimum offset to the nearest rounded slider - * equivalent integer. - * - * @param value the value to convert - * @return the converted value - */ - int progressOf(double value); - - /** - * Converts from slider integer value to an equivalent double value with a given - * minimum offset. - * - * @param progress the value to convert - * @return the converted value - */ - double valueOf(int progress); - - // TODO: also implement linear strategy when needed - - final class Quadratic implements SliderStrategy { - private final double leftGap; - private final double rightGap; - private final double center; - - private final int centerProgress; - - /** - * Quadratic slider strategy that scales the value of a slider given how far the slider - * progress is from the center of the slider. The further away from the center, - * the faster the interpreted value changes, and vice versa. - * - * @param minimum the minimum value of the interpreted value of the slider. - * @param maximum the maximum value of the interpreted value of the slider. - * @param center center of the interpreted value between the minimum and maximum, which - * will be used as the center value on the slider progress. Doesn't need - * to be the average of the minimum and maximum values, but must be in - * between the two. - * @param maxProgress the maximum possible progress of the slider, this is the - * value that is shown for the UI and controls the granularity of - * the slider. Should be as large as possible to avoid floating - * point round-off error. Using odd number is recommended. - */ - public Quadratic(final double minimum, final double maximum, final double center, - final int maxProgress) { - if (center < minimum || center > maximum) { - throw new IllegalArgumentException("Center must be in between minimum and maximum"); - } - - this.leftGap = minimum - center; - this.rightGap = maximum - center; - this.center = center; - - this.centerProgress = maxProgress / 2; - } - - @Override - public int progressOf(final double value) { - final double difference = value - center; - final double root = difference >= 0 ? Math.sqrt(difference / rightGap) - : -Math.sqrt(Math.abs(difference / leftGap)); - final double offset = Math.round(root * centerProgress); - - return (int) (centerProgress + offset); - } - - @Override - public double valueOf(final int progress) { - final int offset = progress - centerProgress; - final double square = Math.pow(((double) offset) / ((double) centerProgress), 2); - final double difference = square * (offset >= 0 ? rightGap : leftGap); - - return difference + center; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java deleted file mode 100644 index 05f26f178..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; - -import java.util.function.Consumer; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -/** - * Utility class for fetching additional data for stream items when needed. - */ -public final class SparseItemUtil { - private SparseItemUtil() { - } - - /** - * Use this to certainly obtain an single play queue with all of the data filled in when the - * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and - * lightweight method to fetch info, but the info might be incomplete (see - * {@link org.schabi.newpipe.local.feed.service.FeedLoadService} for more details). - * - * @param context Android context - * @param item item which is checked and eventually loaded completely - * @param callback callback to call with the single play queue built from the original item if - * all info was available, otherwise from the fetched {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} - */ - public static void fetchItemInfoIfSparse(@NonNull final Context context, - @NonNull final StreamInfoItem item, - @NonNull final Consumer callback) { - if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) - && !isNullOrEmpty(item.getUploaderUrl())) { - // if the duration is >= 0 (provided that the item is not a livestream) and there is an - // uploader url, probably all info is already there, so there is no need to fetch it - callback.accept(new SinglePlayQueue(item)); - return; - } - - // either the duration or the uploader url are not available, so fetch more info - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - streamInfo -> callback.accept(new SinglePlayQueue(streamInfo))); - } - - /** - * Use this to certainly obtain an uploader url when the stream info item or play queue item you - * are handling might not have the uploader url (e.g. because it was fetched with {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}). A toast is shown if loading details is - * required. - * - * @param context Android context - * @param serviceId serviceId of the item - * @param url item url - * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched - * @param callback callback to be called with either the original uploaderUrl, if it was a - * valid url, otherwise with the uploader url obtained by fetching the {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} corresponding to the item - */ - public static void fetchUploaderUrlIfSparse(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - @Nullable final String uploaderUrl, - @NonNull final Consumer callback) { - if (!isNullOrEmpty(uploaderUrl)) { - callback.accept(uploaderUrl); - return; - } - fetchStreamInfoAndSaveToDatabase(context, serviceId, url, - streamInfo -> callback.accept(streamInfo.getUploaderUrl())); - } - - /** - * Loads the stream info corresponding to the given data on an I/O thread, stores the result in - * the database and calls the callback on the main thread with the result. A toast will be shown - * to the user about loading stream details, so this needs to be called on the main thread. - * - * @param context Android context - * @param serviceId service id of the stream to load - * @param url url of the stream to load - * @param callback callback to be called with the result - */ - public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - final Consumer callback) { - Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); - ExtractorHelper.getStreamInfo(serviceId, url, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - // save to database in the background (not on main thread) - Completable.fromAction(() -> NewPipeDatabase.getInstance(context) - .streamDAO().upsert(new StreamEntity(result))) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .doOnError(throwable -> - ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Saving stream info to database", result))) - .subscribe(); - - // call callback on main thread with the obtained result - callback.accept(result); - }, throwable -> ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Loading stream info: " + url, serviceId, url) - )); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java deleted file mode 100644 index 61fdb602f..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * StateSaver.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.util; - - -import android.content.Context; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.os.BundleCompat; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.MainActivity; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.util.LinkedList; -import java.util.Queue; -import java.util.concurrent.ConcurrentHashMap; - -/** - * A way to save state to disk or in a in-memory map - * if it's just changing configurations (i.e. rotating the phone). - */ -public final class StateSaver { - public static final String KEY_SAVED_STATE = "key_saved_state"; - private static final ConcurrentHashMap> STATE_OBJECTS_HOLDER = - new ConcurrentHashMap<>(); - private static final String TAG = "StateSaver"; - private static final String CACHE_DIR_NAME = "state_cache"; - private static String cacheDirPath; - - private StateSaver() { - //no instance - } - - /** - * Initialize the StateSaver, usually you want to call this in the Application class. - * - * @param context used to get the available cache dir - */ - public static void init(final Context context) { - final File externalCacheDir = context.getExternalCacheDir(); - if (externalCacheDir != null) { - cacheDirPath = externalCacheDir.getAbsolutePath(); - } - if (TextUtils.isEmpty(cacheDirPath)) { - cacheDirPath = context.getCacheDir().getAbsolutePath(); - } - } - - /** - * @param outState - * @param writeRead - * @return the saved state - * @see #tryToRestore(SavedState, WriteRead) - */ - public static SavedState tryToRestore(final Bundle outState, final WriteRead writeRead) { - if (outState == null || writeRead == null) { - return null; - } - - final SavedState savedState = BundleCompat.getParcelable( - outState, KEY_SAVED_STATE, SavedState.class); - if (savedState == null) { - return null; - } - - return tryToRestore(savedState, writeRead); - } - - /** - * Try to restore the state from memory and disk, - * using the {@link StateSaver.WriteRead#readFrom(Queue)} from the writeRead. - * - * @param savedState - * @param writeRead - * @return the saved state - */ - @Nullable - private static SavedState tryToRestore(@NonNull final SavedState savedState, - @NonNull final WriteRead writeRead) { - if (MainActivity.DEBUG) { - Log.d(TAG, "tryToRestore() called with: savedState = [" + savedState + "], " - + "writeRead = [" + writeRead + "]"); - } - - try { - Queue savedObjects = - STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); - if (savedObjects != null) { - writeRead.readFrom(savedObjects); - if (MainActivity.DEBUG) { - Log.d(TAG, "tryToSave: reading objects from holder > " + savedObjects - + ", stateObjectsHolder > " + STATE_OBJECTS_HOLDER); - } - return savedState; - } - - final File file = new File(savedState.getPathFileSaved()); - if (!file.exists()) { - if (MainActivity.DEBUG) { - Log.d(TAG, "Cache file doesn't exist: " + file.getAbsolutePath()); - } - return null; - } - - try (FileInputStream fileInputStream = new FileInputStream(file); - ObjectInputStream inputStream = new ObjectInputStream(fileInputStream)) { - //noinspection unchecked - savedObjects = (Queue) inputStream.readObject(); - } - - if (savedObjects != null) { - writeRead.readFrom(savedObjects); - } - - return savedState; - } catch (final Exception e) { - Log.e(TAG, "Failed to restore state", e); - } - return null; - } - - /** - * @param isChangingConfig - * @param savedState - * @param outState - * @param writeRead - * @return the saved state or {@code null} - * @see #tryToSave(boolean, String, String, WriteRead) - */ - @Nullable - public static SavedState tryToSave(final boolean isChangingConfig, - @Nullable final SavedState savedState, final Bundle outState, - final WriteRead writeRead) { - @NonNull final String currentSavedPrefix; - if (savedState == null || TextUtils.isEmpty(savedState.getPrefixFileSaved())) { - // Generate unique prefix - currentSavedPrefix = System.nanoTime() - writeRead.hashCode() + ""; - } else { - // Reuse prefix - currentSavedPrefix = savedState.getPrefixFileSaved(); - } - - final SavedState newSavedState = tryToSave(isChangingConfig, currentSavedPrefix, - writeRead.generateSuffix(), writeRead); - if (newSavedState != null) { - outState.putParcelable(StateSaver.KEY_SAVED_STATE, newSavedState); - return newSavedState; - } - - return null; - } - - /** - * If it's not changing configuration (i.e. rotating screen), - * try to write the state from {@link StateSaver.WriteRead#writeTo(Queue)} - * to the file with the name of prefixFileName + suffixFileName, - * in a cache folder got from the {@link #init(Context)}. - *

- * It checks if the file already exists and if it does, just return the path, - * so a good way to save is: - *

- *
    - *
  • A fixed prefix for the file
  • - *
  • A changing suffix
  • - *
- * - * @param isChangingConfig - * @param prefixFileName - * @param suffixFileName - * @param writeRead - * @return the saved state or {@code null} - */ - @Nullable - private static SavedState tryToSave(final boolean isChangingConfig, final String prefixFileName, - final String suffixFileName, final WriteRead writeRead) { - if (MainActivity.DEBUG) { - Log.d(TAG, "tryToSave() called with: " - + "isChangingConfig = [" + isChangingConfig + "], " - + "prefixFileName = [" + prefixFileName + "], " - + "suffixFileName = [" + suffixFileName + "], " - + "writeRead = [" + writeRead + "]"); - } - - final LinkedList savedObjects = new LinkedList<>(); - writeRead.writeTo(savedObjects); - - if (isChangingConfig) { - if (savedObjects.size() > 0) { - STATE_OBJECTS_HOLDER.put(prefixFileName, savedObjects); - return new SavedState(prefixFileName, ""); - } else { - if (MainActivity.DEBUG) { - Log.d(TAG, "Nothing to save"); - } - return null; - } - } - - try { - File cacheDir = new File(cacheDirPath); - if (!cacheDir.exists()) { - throw new RuntimeException("Cache dir does not exist > " + cacheDirPath); - } - cacheDir = new File(cacheDir, CACHE_DIR_NAME); - if (!cacheDir.exists()) { - if (!cacheDir.mkdir()) { - if (BuildConfig.DEBUG) { - Log.e(TAG, - "Failed to create cache directory " + cacheDir.getAbsolutePath()); - } - return null; - } - } - - final File file = new File(cacheDir, prefixFileName - + (TextUtils.isEmpty(suffixFileName) ? ".cache" : suffixFileName)); - if (file.exists() && file.length() > 0) { - // If the file already exists, just return it - return new SavedState(prefixFileName, file.getAbsolutePath()); - } else { - // Delete any file that contains the prefix - final File[] files = cacheDir.listFiles((dir, name) -> - name.contains(prefixFileName)); - for (final File fileToDelete : files) { - fileToDelete.delete(); - } - } - - try (FileOutputStream fileOutputStream = new FileOutputStream(file); - ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream)) { - outputStream.writeObject(savedObjects); - } - - return new SavedState(prefixFileName, file.getAbsolutePath()); - } catch (final Exception e) { - Log.e(TAG, "Failed to save state", e); - } - return null; - } - - /** - * Delete the cache file contained in the savedState. - * Also remove any possible-existing value in the memory-cache. - * - * @param savedState the saved state to delete - */ - public static void onDestroy(final SavedState savedState) { - if (MainActivity.DEBUG) { - Log.d(TAG, "onDestroy() called with: savedState = [" + savedState + "]"); - } - - if (savedState != null && !savedState.getPathFileSaved().isEmpty()) { - STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); - try { - //noinspection ResultOfMethodCallIgnored - new File(savedState.getPathFileSaved()).delete(); - } catch (final Exception ignored) { - } - } - } - - /** - * Clear all the files in cache (in memory and disk). - */ - public static void clearStateFiles() { - if (MainActivity.DEBUG) { - Log.d(TAG, "clearStateFiles() called"); - } - - STATE_OBJECTS_HOLDER.clear(); - File cacheDir = new File(cacheDirPath); - if (!cacheDir.exists()) { - return; - } - - cacheDir = new File(cacheDir, CACHE_DIR_NAME); - if (cacheDir.exists()) { - final File[] list = cacheDir.listFiles(); - if (list != null) { - for (final File file : list) { - file.delete(); - } - } - } - } - - /** - * Used for describing how to save/read the objects. - *

- * Queue was chosen by its FIFO property. - */ - public interface WriteRead { - /** - * Generate a changing suffix that will name the cache file, - * and be used to identify if it changed (thus reducing useless reading/saving). - * - * @return a unique value - */ - String generateSuffix(); - - /** - * Add to this queue objects that you want to save. - * - * @param objectsToSave the objects to save - */ - void writeTo(Queue objectsToSave); - - /** - * Poll saved objects from the queue in the order they were written. - * - * @param savedObjects queue of objects returned by {@link #writeTo(Queue)} - */ - void readFrom(@NonNull Queue savedObjects) throws Exception; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java deleted file mode 100644 index 2eeb14b1b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ /dev/null @@ -1,470 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.ImageView; -import android.widget.Spinner; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.collection.SparseArrayCompat; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.downloader.Response; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.extractor.utils.Utils; - -import java.io.Serializable; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; -import us.shandian.giga.util.Utility; - -/** - * A list adapter for a list of {@link Stream streams}. - * It currently supports {@link VideoStream}, {@link AudioStream} and {@link SubtitlesStream}. - * - * @param the primary stream type's class extending {@link Stream} - * @param the secondary stream type's class extending {@link Stream} - */ -public class StreamItemAdapter extends BaseAdapter { - @NonNull - private final StreamInfoWrapper streamsWrapper; - @NonNull - private final SparseArrayCompat> secondaryStreams; - - /** - * Indicates that at least one of the primary streams is an instance of {@link VideoStream}, - * has no audio ({@link VideoStream#isVideoOnly()} returns true) and has no secondary stream - * associated with it. - */ - private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; - - public StreamItemAdapter( - @NonNull final StreamInfoWrapper streamsWrapper, - @NonNull final SparseArrayCompat> secondaryStreams - ) { - this.streamsWrapper = streamsWrapper; - this.secondaryStreams = secondaryStreams; - - this.hasAnyVideoOnlyStreamWithNoSecondaryStream = - checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); - } - - public StreamItemAdapter(final StreamInfoWrapper streamsWrapper) { - this(streamsWrapper, new SparseArrayCompat<>(0)); - } - - public List getAll() { - return streamsWrapper.getStreamsList(); - } - - public SparseArrayCompat> getAllSecondary() { - return secondaryStreams; - } - - @Override - public int getCount() { - return streamsWrapper.getStreamsList().size(); - } - - @Override - public T getItem(final int position) { - return streamsWrapper.getStreamsList().get(position); - } - - @Override - public long getItemId(final int position) { - return position; - } - - @Override - public View getDropDownView(final int position, - final View convertView, - final ViewGroup parent) { - return getCustomView(position, convertView, parent, true); - } - - @Override - public View getView(final int position, final View convertView, final ViewGroup parent) { - return getCustomView(((Spinner) parent).getSelectedItemPosition(), - convertView, parent, false); - } - - @NonNull - private View getCustomView(final int position, - final View view, - final ViewGroup parent, - final boolean isDropdownItem) { - final var context = parent.getContext(); - View convertView = view; - if (convertView == null) { - convertView = LayoutInflater.from(context).inflate( - R.layout.stream_quality_item, parent, false); - } - - final ImageView woSoundIconView = convertView.findViewById(R.id.wo_sound_icon); - final TextView formatNameView = convertView.findViewById(R.id.stream_format_name); - final TextView qualityView = convertView.findViewById(R.id.stream_quality); - final TextView sizeView = convertView.findViewById(R.id.stream_size); - - final T stream = getItem(position); - final MediaFormat mediaFormat = streamsWrapper.getFormat(position); - - int woSoundIconVisibility = View.GONE; - String qualityString; - - if (stream instanceof VideoStream) { - final VideoStream videoStream = ((VideoStream) stream); - qualityString = videoStream.getResolution(); - - if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { - if (videoStream.isVideoOnly()) { - woSoundIconVisibility = secondaryStreams.get(position) != null - // It has a secondary stream associated with it, so check if it's a - // dropdown view so it doesn't look out of place (missing margin) - // compared to those that don't. - ? (isDropdownItem ? View.INVISIBLE : View.GONE) - // It doesn't have a secondary stream, icon is visible no matter what. - : View.VISIBLE; - } else if (isDropdownItem) { - woSoundIconVisibility = View.INVISIBLE; - } - } - } else if (stream instanceof AudioStream) { - final AudioStream audioStream = ((AudioStream) stream); - if (audioStream.getAverageBitrate() > 0) { - qualityString = audioStream.getAverageBitrate() + "kbps"; - } else { - qualityString = context.getString(R.string.unknown_quality); - } - } else if (stream instanceof SubtitlesStream) { - qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); - if (((SubtitlesStream) stream).isAutoGenerated()) { - qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; - } - } else { - if (mediaFormat == null) { - qualityString = context.getString(R.string.unknown_quality); - } else { - qualityString = mediaFormat.getSuffix(); - } - } - - if (streamsWrapper.getSizeInBytes(position) > 0) { - final var secondary = secondaryStreams.get(position); - if (secondary != null) { - final long size = secondary.getSizeInBytes() - + streamsWrapper.getSizeInBytes(position); - sizeView.setText(Utility.formatBytes(size)); - } else { - sizeView.setText(streamsWrapper.getFormattedSize(position)); - } - sizeView.setVisibility(View.VISIBLE); - } else { - sizeView.setVisibility(View.GONE); - } - - if (stream instanceof SubtitlesStream) { - formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); - } else { - if (mediaFormat == null) { - formatNameView.setText(context.getString(R.string.unknown_format)); - } else if (mediaFormat == MediaFormat.WEBMA_OPUS) { - // noinspection AndroidLintSetTextI18n - formatNameView.setText("opus"); - } else { - formatNameView.setText(mediaFormat.getName()); - } - } - - qualityView.setText(qualityString); - woSoundIconView.setVisibility(woSoundIconVisibility); - - return convertView; - } - - /** - * @return if there are any video-only streams with no secondary stream associated with them. - * @see #hasAnyVideoOnlyStreamWithNoSecondaryStream - */ - private boolean checkHasAnyVideoOnlyStreamWithNoSecondaryStream() { - for (int i = 0; i < streamsWrapper.getStreamsList().size(); i++) { - final T stream = streamsWrapper.getStreamsList().get(i); - if (stream instanceof VideoStream) { - final boolean videoOnly = ((VideoStream) stream).isVideoOnly(); - if (videoOnly && secondaryStreams.get(i) == null) { - return true; - } - } - } - - return false; - } - - /** - * A wrapper class that includes a way of storing the stream sizes. - * - * @param the stream type's class extending {@link Stream} - */ - public static class StreamInfoWrapper implements Serializable { - private static final StreamInfoWrapper EMPTY = - new StreamInfoWrapper<>(Collections.emptyList(), null); - private static final int SIZE_UNSET = -2; - - private final List streamsList; - private final long[] streamSizes; - private final MediaFormat[] streamFormats; - private final String unknownSize; - - public StreamInfoWrapper(@NonNull final List streamList, - @Nullable final Context context) { - this.streamsList = streamList; - this.streamSizes = new long[streamsList.size()]; - this.unknownSize = context == null - ? "--.-" : context.getString(R.string.unknown_content); - this.streamFormats = new MediaFormat[streamsList.size()]; - resetInfo(); - } - - /** - * Helper method to fetch the sizes and missing media formats - * of all the streams in a wrapper. - * - * @param the stream type's class extending {@link Stream} - * @param streamsWrapper the wrapper - * @return a {@link Single} that returns a boolean indicating if any elements were changed - */ - @NonNull - public static Single fetchMoreInfoForWrapper( - final StreamInfoWrapper streamsWrapper) { - final Callable fetchAndSet = () -> { - boolean hasChanged = false; - for (final X stream : streamsWrapper.getStreamsList()) { - final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET; - final boolean changeFormat = stream.getFormat() == null; - if (!changeSize && !changeFormat) { - continue; - } - final Response response = DownloaderImpl.getInstance() - .head(stream.getContent()); - if (changeSize) { - final String contentLength = response.getHeader("Content-Length"); - if (!isNullOrEmpty(contentLength)) { - streamsWrapper.setSize(stream, Long.parseLong(contentLength)); - hasChanged = true; - } - } - if (changeFormat) { - hasChanged = retrieveMediaFormat(stream, streamsWrapper, response) - || hasChanged; - } - } - return hasChanged; - }; - - return Single.fromCallable(fetchAndSet) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .onErrorReturnItem(true); - } - - /** - * Try to retrieve the {@link MediaFormat} for a stream from the request headers. - * - * @param the stream type to get the {@link MediaFormat} for - * @param stream the stream to find the {@link MediaFormat} for - * @param streamsWrapper the wrapper to store the found {@link MediaFormat} in - * @param response the response of the head request for the given stream - * @return {@code true} if the media format could be retrieved; {@code false} otherwise - */ - @VisibleForTesting - public static boolean retrieveMediaFormat( - @NonNull final X stream, - @NonNull final StreamInfoWrapper streamsWrapper, - @NonNull final Response response) { - return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response) - || retrieveMediaFormatFromContentDispositionHeader( - stream, streamsWrapper, response) - || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response); - } - - @VisibleForTesting - public static boolean retrieveMediaFormatFromFileTypeHeaders( - @NonNull final X stream, - @NonNull final StreamInfoWrapper streamsWrapper, - @NonNull final Response response) { - // try to use additional headers from CDNs or servers, - // e.g. x-amz-meta-file-type (e.g. for SoundCloud) - final List keys = response.responseHeaders().keySet().stream() - .filter(k -> k.endsWith("file-type")).collect(Collectors.toList()); - if (!keys.isEmpty()) { - for (final String key : keys) { - final String suffix = response.getHeader(key); - final MediaFormat format = MediaFormat.getFromSuffix(suffix); - if (format != null) { - streamsWrapper.setFormat(stream, format); - return true; - } - } - } - return false; - } - - /** - *

Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header - * for a stream and store the info in a wrapper.

- * @see - * - * mdn Web Docs for the HTTP Content-Disposition Header - * @param stream the stream to get the {@link MediaFormat} for - * @param streamsWrapper the wrapper to store the {@link MediaFormat} in - * @param response the response to get the Content-Disposition header from - * @return {@code true} if the {@link MediaFormat} could be retrieved from the response; - * otherwise {@code false} - * @param - */ - @VisibleForTesting - public static boolean retrieveMediaFormatFromContentDispositionHeader( - @NonNull final X stream, - @NonNull final StreamInfoWrapper streamsWrapper, - @NonNull final Response response) { - // parse the Content-Disposition header, - // see - // there can be two filename directives - String contentDisposition = response.getHeader("Content-Disposition"); - if (contentDisposition == null) { - return false; - } - try { - contentDisposition = Utils.decodeUrlUtf8(contentDisposition); - final String[] parts = contentDisposition.split(";"); - for (String part : parts) { - final String fileName; - part = part.trim(); - - // extract the filename - if (part.startsWith("filename=")) { - // remove directive and decode - fileName = Utils.decodeUrlUtf8(part.substring(9)); - } else if (part.startsWith("filename*=")) { - fileName = Utils.decodeUrlUtf8(part.substring(10)); - } else { - continue; - } - - // extract the file extension / suffix - final String[] p = fileName.split("\\."); - String suffix = p[p.length - 1]; - if (suffix.endsWith("\"") || suffix.endsWith("'")) { - // remove trailing quotes if present, end index is exclusive - suffix = suffix.substring(0, suffix.length() - 1); - } - - // get the corresponding media format - final MediaFormat format = MediaFormat.getFromSuffix(suffix); - if (format != null) { - streamsWrapper.setFormat(stream, format); - return true; - } - } - } catch (final Exception ignored) { - // fail silently - } - return false; - } - - @VisibleForTesting - public static boolean retrieveMediaFormatFromContentTypeHeader( - @NonNull final X stream, - @NonNull final StreamInfoWrapper streamsWrapper, - @NonNull final Response response) { - // try to get the format by content type - // some mime types are not unique for every format, those are omitted - final String contentTypeHeader = response.getHeader("Content-Type"); - if (contentTypeHeader == null) { - return false; - } - - @Nullable MediaFormat foundFormat = null; - for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) { - if (foundFormat == null) { - foundFormat = format; - } else if (foundFormat.id != format.id) { - return false; - } - } - if (foundFormat != null) { - streamsWrapper.setFormat(stream, foundFormat); - return true; - } - return false; - } - - public void resetInfo() { - Arrays.fill(streamSizes, SIZE_UNSET); - for (int i = 0; i < streamsList.size(); i++) { - streamFormats[i] = streamsList.get(i) == null // test for invalid streams - ? null : streamsList.get(i).getFormat(); - } - } - - public static StreamInfoWrapper empty() { - //noinspection unchecked - return (StreamInfoWrapper) EMPTY; - } - - public List getStreamsList() { - return streamsList; - } - - public long getSizeInBytes(final int streamIndex) { - return streamSizes[streamIndex]; - } - - public long getSizeInBytes(final T stream) { - return streamSizes[streamsList.indexOf(stream)]; - } - - public String getFormattedSize(final int streamIndex) { - return formatSize(getSizeInBytes(streamIndex)); - } - - private String formatSize(final long size) { - if (size > -1) { - return Utility.formatBytes(size); - } - return unknownSize; - } - - public void setSize(final T stream, final long sizeInBytes) { - streamSizes[streamsList.indexOf(stream)] = sizeInBytes; - } - - public MediaFormat getFormat(final int streamIndex) { - return streamFormats[streamIndex]; - } - - public void setFormat(final T stream, final MediaFormat format) { - streamFormats[streamsList.indexOf(stream)] = format; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt deleted file mode 100644 index 2401a4f49..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util - -import org.schabi.newpipe.extractor.stream.StreamType - -/** - * Utility class for [StreamType]. - */ -object StreamTypeUtil { - /** - * Check if the [StreamType] of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is [StreamType.AUDIO_STREAM], - * [StreamType.AUDIO_LIVE_STREAM] or [StreamType.POST_LIVE_AUDIO_STREAM] - */ - @JvmStatic - fun isAudio(streamType: StreamType): Boolean { - return streamType == StreamType.AUDIO_STREAM || - streamType == StreamType.AUDIO_LIVE_STREAM || - streamType == StreamType.POST_LIVE_AUDIO_STREAM - } - - /** - * Check if the [StreamType] of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is [StreamType.VIDEO_STREAM], - * [StreamType.LIVE_STREAM] or [StreamType.POST_LIVE_STREAM] - */ - @JvmStatic - fun isVideo(streamType: StreamType): Boolean { - return streamType == StreamType.VIDEO_STREAM || - streamType == StreamType.LIVE_STREAM || - streamType == StreamType.POST_LIVE_STREAM - } - - /** - * Check if the [StreamType] of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is [StreamType.LIVE_STREAM] or - * [StreamType.AUDIO_LIVE_STREAM] - */ - @JvmStatic - fun isLiveStream(streamType: StreamType): Boolean { - return streamType == StreamType.LIVE_STREAM || - streamType == StreamType.AUDIO_LIVE_STREAM - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java deleted file mode 100644 index 24a0f457f..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ /dev/null @@ -1,415 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * ThemeHelper.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.util; - -import android.app.Activity; -import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.drawable.Drawable; -import android.util.TypedValue; - -import androidx.annotation.AttrRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StyleRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.info_list.ItemViewMode; - -public final class ThemeHelper { - private ThemeHelper() { - } - - /** - * Apply the selected theme (on NewPipe settings) in the context - * with the default style (see {@link #setTheme(Context, int)}). - * - * ThemeHelper.setDayNightMode should be called before - * the applying theme for the first time in session - * - * @param context context that the theme will be applied - */ - public static void setTheme(final Context context) { - setTheme(context, -1); - } - - /** - * Apply the selected theme (on NewPipe settings) in the context, - * themed according with the styles defined for the service . - * - * ThemeHelper.setDayNightMode should be called before - * the applying theme for the first time in session - * - * @param context context that the theme will be applied - * @param serviceId the theme will be styled to the service with this id, - * pass -1 to get the default style - */ - public static void setTheme(final Context context, final int serviceId) { - context.setTheme(getThemeForService(context, serviceId)); - } - - /** - * Return true if the selected theme (on NewPipe settings) is the Light theme. - * - * @param context context to get the preference - * @return whether the light theme is selected - */ - public static boolean isLightThemeSelected(final Context context) { - final String selectedThemeKey = getSelectedThemeKey(context); - final Resources res = context.getResources(); - - return selectedThemeKey.equals(res.getString(R.string.light_theme_key)) - || (selectedThemeKey.equals(res.getString(R.string.auto_device_theme_key)) - && !isDeviceDarkThemeEnabled(context)); - } - - /** - * Return a dialog theme styled according to the (default) selected theme. - * - * @param context context to get the selected theme - * @return the dialog style (the default one) - */ - @StyleRes - public static int getDialogTheme(final Context context) { - return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; - } - - /** - * Return a min-width dialog theme styled according to the (default) selected theme. - * - * @param context context to get the selected theme - * @return the dialog style (the default one) - */ - @StyleRes - public static int getMinWidthDialogTheme(final Context context) { - return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme - : R.style.DarkDialogMinWidthTheme; - } - - /** - * Return the selected theme styled according to the serviceId. - * - * @param context context to get the selected theme - * @param serviceId return a theme styled to this service, - * -1 to get the default - * @return the selected style (styled) - */ - @StyleRes - public static int getThemeForService(final Context context, final int serviceId) { - final Resources res = context.getResources(); - final String lightThemeKey = res.getString(R.string.light_theme_key); - final String blackThemeKey = res.getString(R.string.black_theme_key); - final String automaticDeviceThemeKey = res.getString(R.string.auto_device_theme_key); - - final String selectedThemeKey = getSelectedThemeKey(context); - - - int baseTheme = R.style.DarkTheme; // default to dark theme - if (selectedThemeKey.equals(lightThemeKey)) { - baseTheme = R.style.LightTheme; - } else if (selectedThemeKey.equals(blackThemeKey)) { - baseTheme = R.style.BlackTheme; - } else if (selectedThemeKey.equals(automaticDeviceThemeKey)) { - - if (isDeviceDarkThemeEnabled(context)) { - // use the dark theme variant preferred by the user - final String selectedNightThemeKey = getSelectedNightThemeKey(context); - if (selectedNightThemeKey.equals(blackThemeKey)) { - baseTheme = R.style.BlackTheme; - } else { - baseTheme = R.style.DarkTheme; - } - } else { - // there is only one day theme - baseTheme = R.style.LightTheme; - } - } - - if (serviceId <= -1) { - return baseTheme; - } - - final StreamingService service; - try { - service = NewPipe.getService(serviceId); - } catch (final ExtractionException ignored) { - return baseTheme; - } - - String themeName = "DarkTheme"; // default - if (baseTheme == R.style.LightTheme) { - themeName = "LightTheme"; - } else if (baseTheme == R.style.BlackTheme) { - themeName = "BlackTheme"; - } - - themeName += "." + service.getServiceInfo().getName(); - final int resourceId = context.getResources() - .getIdentifier(themeName, "style", context.getPackageName()); - - if (resourceId > 0) { - return resourceId; - } - return baseTheme; - } - - @StyleRes - public static int getSettingsThemeStyle(final Context context) { - final Resources res = context.getResources(); - final String lightTheme = res.getString(R.string.light_theme_key); - final String blackTheme = res.getString(R.string.black_theme_key); - final String automaticDeviceTheme = res.getString(R.string.auto_device_theme_key); - - - final String selectedTheme = getSelectedThemeKey(context); - - if (selectedTheme.equals(lightTheme)) { - return R.style.LightSettingsTheme; - } else if (selectedTheme.equals(blackTheme)) { - return R.style.BlackSettingsTheme; - } else if (selectedTheme.equals(automaticDeviceTheme)) { - if (isDeviceDarkThemeEnabled(context)) { - // use the dark theme variant preferred by the user - final String selectedNightTheme = getSelectedNightThemeKey(context); - if (selectedNightTheme.equals(blackTheme)) { - return R.style.BlackSettingsTheme; - } else { - return R.style.DarkSettingsTheme; - } - } else { - // there is only one day theme - return R.style.LightSettingsTheme; - } - } else { - // default to dark theme - return R.style.DarkSettingsTheme; - } - } - - /** - * Get a color from an attr styled according to the context's theme. - * - * @param context Android app context - * @param attrColor attribute reference of the resource - * @return the color - */ - public static int resolveColorFromAttr(final Context context, @AttrRes final int attrColor) { - final TypedValue value = new TypedValue(); - context.getTheme().resolveAttribute(attrColor, value, true); - - if (value.resourceId != 0) { - return ContextCompat.getColor(context, value.resourceId); - } - - return value.data; - } - - /** - * Resolves a {@link Drawable} by it's id. - * - * @param context Context - * @param attrResId Resource id - * @return the {@link Drawable} - */ - public static Drawable resolveDrawable(@NonNull final Context context, - @AttrRes final int attrResId) { - final TypedValue typedValue = new TypedValue(); - context.getTheme().resolveAttribute(attrResId, typedValue, true); - return AppCompatResources.getDrawable(context, typedValue.resourceId); - } - - /** - * Gets a runtime dimen from the {@code android} package. Should be used for dimens for which - * normal accessing with {@code R.dimen.} is not available. - * - * @param context context - * @param name dimen resource name (e.g. navigation_bar_height) - * @return the obtained dimension, in pixels, or 0 if the resource could not be resolved - */ - public static int getAndroidDimenPx(@NonNull final Context context, final String name) { - final int resId = context.getResources().getIdentifier(name, "dimen", "android"); - if (resId <= 0) { - return 0; - } - return context.getResources().getDimensionPixelSize(resId); - } - - private static String getSelectedThemeKey(final Context context) { - final String themeKey = context.getString(R.string.theme_key); - final String defaultTheme = context.getString(R.string.default_theme_value); - return PreferenceManager.getDefaultSharedPreferences(context) - .getString(themeKey, defaultTheme); - } - - private static String getSelectedNightThemeKey(final Context context) { - final String nightThemeKey = context.getString(R.string.night_theme_key); - final String defaultNightTheme = context.getResources() - .getString(R.string.default_night_theme_value); - return PreferenceManager.getDefaultSharedPreferences(context) - .getString(nightThemeKey, defaultNightTheme); - } - - /** - * Sets the title to the activity, if the activity is an {@link AppCompatActivity} and has an - * action bar. - * - * @param activity the activity to set the title of - * @param title the title to set to the activity - */ - public static void setTitleToAppCompatActivity(@Nullable final Activity activity, - final CharSequence title) { - if (activity instanceof AppCompatActivity) { - final ActionBar actionBar = ((AppCompatActivity) activity).getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(title); - } - } - } - - /** - * Get the device theme - *

- * It will return true if the device 's theme is dark, false otherwise. - *

- * From https://developer.android.com/guide/topics/ui/look-and-feel/darktheme#java - * - * @param context the context to use - * @return true:dark theme, false:light or unknown - */ - public static boolean isDeviceDarkThemeEnabled(final Context context) { - final int deviceTheme = context.getResources().getConfiguration().uiMode - & Configuration.UI_MODE_NIGHT_MASK; - switch (deviceTheme) { - case Configuration.UI_MODE_NIGHT_YES: - return true; - case Configuration.UI_MODE_NIGHT_UNDEFINED: - case Configuration.UI_MODE_NIGHT_NO: - default: - return false; - } - } - - public static void setDayNightMode(final Context context) { - setDayNightMode(context, ThemeHelper.getSelectedThemeKey(context)); - } - - public static void setDayNightMode(final Context context, final String selectedThemeKey) { - final Resources res = context.getResources(); - - if (selectedThemeKey.equals(res.getString(R.string.light_theme_key))) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } else if (selectedThemeKey.equals(res.getString(R.string.dark_theme_key)) - || selectedThemeKey.equals(res.getString(R.string.black_theme_key))) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - } else { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); - } - } - - /** - * Returns whether the grid layout or the list layout should be used. If the user set "auto" - * mode in settings, decides based on screen orientation (landscape) and size. - * - * @param context the context to use - * @return true:use grid layout, false:use list layout - */ - public static boolean shouldUseGridLayout(final Context context) { - final ItemViewMode mode = getItemViewMode(context); - return mode == ItemViewMode.GRID; - } - - /** - * Calculates the number of grid channel info items that can fit horizontally on the screen. - * - * @param context the context to use - * @return the span count of grid channel info items - */ - public static int getGridSpanCountChannels(final Context context) { - return getGridSpanCount(context, - context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width)); - } - - /** - * Returns item view mode. - * @param context to read preference and parse string - * @return Returns one of ItemViewMode - */ - public static ItemViewMode getItemViewMode(final Context context) { - final String listMode = PreferenceManager.getDefaultSharedPreferences(context) - .getString(context.getString(R.string.list_view_mode_key), - context.getString(R.string.list_view_mode_value)); - final ItemViewMode result; - if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { - result = ItemViewMode.LIST; - } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { - result = ItemViewMode.GRID; - } else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) { - result = ItemViewMode.CARD; - } else { - // Auto mode - evaluate whether to use Grid based on screen real estate. - final Configuration configuration = context.getResources().getConfiguration(); - final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); - if (useGrid) { - result = ItemViewMode.GRID; - } else { - result = ItemViewMode.LIST; - } - } - return result; - } - - /** - * Calculates the number of grid stream info items that can fit horizontally on the screen. The - * width of a grid stream info item is obtained from the thumbnail width plus the right and left - * paddings. - * - * @param context the context to use - * @return the span count of grid stream info items - */ - public static int getGridSpanCountStreams(final Context context) { - final Resources res = context.getResources(); - return getGridSpanCount(context, - res.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) - + res.getDimensionPixelSize(R.dimen.video_item_search_padding) * 2); - } - - /** - * Calculates the number of grid items that can fit horizontally on the screen based on the - * minimum width. - * - * @param context the context to use - * @param minWidth the minimum width of items in the grid - * @return the span count of grid list items - */ - public static int getGridSpanCount(final Context context, final int minWidth) { - return Math.max(1, context.getResources().getDisplayMetrics().widthPixels / minWidth); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java deleted file mode 100644 index fefd50e3c..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util; - -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; - -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -public final class ZipHelper { - @FunctionalInterface - public interface InputStreamConsumer { - void acceptStream(InputStream inputStream) throws IOException; - } - - @FunctionalInterface - public interface OutputStreamConsumer { - void acceptStream(OutputStream outputStream) throws IOException; - } - - - private ZipHelper() { } - - - /** - * This function helps to create zip files. Caution, this will overwrite the original file. - * - * @param outZip the ZipOutputStream where the data should be stored in - * @param nameInZip the path of the file inside the zip - * @param path the path of the file on the disk that should be added to zip - */ - public static void addFileToZip(final ZipOutputStream outZip, - final String nameInZip, - final Path path) throws IOException { - try (var inputStream = Files.newInputStream(path)) { - addFileToZip(outZip, nameInZip, inputStream); - } - } - - /** - * This function helps to create zip files. Caution this will overwrite the original file. - * - * @param outZip the ZipOutputStream where the data should be stored in - * @param nameInZip the path of the file inside the zip - * @param streamConsumer will be called with an output stream that will go to the output file - */ - public static void addFileToZip(final ZipOutputStream outZip, - final String nameInZip, - final OutputStreamConsumer streamConsumer) throws IOException { - final byte[] bytes; - try (var byteOutput = new ByteArrayOutputStream()) { - streamConsumer.acceptStream(byteOutput); - bytes = byteOutput.toByteArray(); - } - - try (var byteInput = new ByteArrayInputStream(bytes)) { - addFileToZip(outZip, nameInZip, byteInput); - } - } - - /** - * This function helps to create zip files. Caution this will overwrite the original file. - * - * @param outZip the ZipOutputStream where the data should be stored in - * @param nameInZip the path of the file inside the zip - * @param inputStream the content to put inside the file - */ - private static void addFileToZip(final ZipOutputStream outZip, - final String nameInZip, - final InputStream inputStream) throws IOException { - outZip.putNextEntry(new ZipEntry(nameInZip)); - inputStream.transferTo(outZip); - } - - /** - * This will extract data from ZipInputStream. Caution, this will overwrite the original file. - * - * @param zipFile the zip file to extract from - * @param nameInZip the path of the file inside the zip - * @param path the path of the file on the disk where the data should be extracted to - * @return will return true if the file was found within the zip file - */ - public static boolean extractFileFromZip(final StoredFileHelper zipFile, - final String nameInZip, - final Path path) throws IOException { - return extractFileFromZip(zipFile, nameInZip, input -> - Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING)); - } - - /** - * This will extract data from ZipInputStream. - * - * @param zipFile the zip file to extract from - * @param nameInZip the path of the file inside the zip - * @param streamConsumer will be called with the input stream from the file inside the zip - * @return will return true if the file was found within the zip file - */ - public static boolean extractFileFromZip(final StoredFileHelper zipFile, - final String nameInZip, - final InputStreamConsumer streamConsumer) - throws IOException { - try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( - new SharpInputStream(zipFile.getStream())))) { - ZipEntry ze; - while ((ze = inZip.getNextEntry()) != null) { - if (ze.getName().equals(nameInZip)) { - streamConsumer.acceptStream(inZip); - return true; - } - } - - return false; - } - } - - /** - * @param zipFile the zip file - * @param fileInZip the filename to check - * @return whether the provided filename is in the zip; only the first level is checked - */ - public static boolean zipContainsFile(final StoredFileHelper zipFile, final String fileInZip) - throws Exception { - try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( - new SharpInputStream(zipFile.getStream())))) { - ZipEntry ze; - - while ((ze = inZip.getNextEntry()) != null) { - if (ze.getName().equals(fileInZip)) { - return true; - } - } - return false; - } - } - - public static boolean isValidZipFile(final StoredFileHelper file) { - try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream( - new SharpInputStream(file.getStream())))) { - return true; - } catch (final IOException ioe) { - return false; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java deleted file mode 100644 index acc515dd6..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.schabi.newpipe.util.debounce; - -import org.schabi.newpipe.error.ErrorInfo; - -public interface DebounceSavable { - - /** - * Execute operations to save the data.
- * Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually - * after the data has been saved. - */ - void saveImmediate(); - - void showError(ErrorInfo errorInfo); -} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java deleted file mode 100644 index 5bd5cdd55..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.schabi.newpipe.util.debounce; - -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; - -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.subjects.PublishSubject; - -public class DebounceSaver { - - private final long saveDebounceMillis; - - private final PublishSubject debouncedSaveSignal; - - private final DebounceSavable debounceSavable; - - // Has the object been modified - private final AtomicBoolean isModified; - - // Default 10 seconds - private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000; - - - /** - * Creates a new {@code DebounceSaver}. - * - * @param saveDebounceMillis Save the object milliseconds later after the last change - * occurred. - * @param debounceSavable The object containing data to be saved. - */ - public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) { - this.saveDebounceMillis = saveDebounceMillis; - debouncedSaveSignal = PublishSubject.create(); - this.debounceSavable = debounceSavable; - this.isModified = new AtomicBoolean(); - } - - /** - * Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change - * occurred. - * - * @param debounceSavable The object containing data to be saved. - */ - public DebounceSaver(final DebounceSavable debounceSavable) { - this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable); - } - - public boolean getIsModified() { - return isModified.get(); - } - - public void setNoChangesToSave() { - isModified.set(false); - } - - public PublishSubject getDebouncedSaveSignal() { - return debouncedSaveSignal; - } - - public Disposable getDebouncedSaver() { - return debouncedSaveSignal - .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> debounceSavable.saveImmediate(), throwable -> - debounceSavable.showError(new ErrorInfo(throwable, - UserAction.SOMETHING_ELSE, "Debounced saver"))); - } - - public void setHasChangesToSave() { - if (isModified == null || debouncedSaveSignal == null) { - return; - } - - isModified.set(true); - debouncedSaveSignal.onNext(System.currentTimeMillis()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java deleted file mode 100644 index 6a605e982..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.schabi.newpipe.util.external_communication; - -import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; -import static org.schabi.newpipe.util.external_communication.ShareUtils.tryOpenIntentInApp; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.ServiceList; - -/** - * Util class that provides methods which are related to the Kodi Media Center and its Kore app. - * @see Kodi website - */ -public final class KoreUtils { - private KoreUtils() { } - - public static boolean isServiceSupportedByKore(final int serviceId) { - return (serviceId == ServiceList.YouTube.getServiceId() - || serviceId == ServiceList.SoundCloud.getServiceId()); - } - - public static boolean shouldShowPlayWithKodi(@NonNull final Context context, - final int serviceId) { - return isServiceSupportedByKore(serviceId) - && PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); - } - - /** - * Start an activity to install Kore. - * - * @param context the context to use - */ - public static void installKore(final Context context) { - installApp(context, context.getString(R.string.kore_package)); - } - - /** - * Start Kore app to show a video on Kodi, and if the app is not installed ask the user to - * install it. - *

- * For a list of supported urls see the - * - * Kore source code - * . - * - * @param context the context to use - * @param streamUrl the url to the stream to play - */ - public static void playWithKore(final Context context, final Uri streamUrl) { - final Intent intent = new Intent(Intent.ACTION_VIEW) - .setPackage(context.getString(R.string.kore_package)) - .setData(streamUrl) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (!tryOpenIntentInApp(context, intent)) { - new AlertDialog.Builder(context) - .setMessage(R.string.kore_not_found) - .setPositiveButton(R.string.install, (dialog, which) -> - installKore(context)) - .setNegativeButton(R.string.cancel, null) - .show(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java deleted file mode 100644 index d56362105..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ /dev/null @@ -1,418 +0,0 @@ -package org.schabi.newpipe.util.external_communication; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static coil3.Image_androidKt.toBitmap; - -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.Build; -import android.text.TextUtils; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.R; -import org.schabi.newpipe.RouterActivity; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.util.image.ImageStrategy; - -import java.nio.file.Files; -import java.util.Collections; -import java.util.List; - -import coil3.SingletonImageLoader; -import coil3.disk.DiskCache; -import coil3.memory.MemoryCache; - -public final class ShareUtils { - private static final String TAG = ShareUtils.class.getSimpleName(); - - private ShareUtils() { - } - - /** - * Open an Intent to install an app. - *

- * This method tries to open the default app market with the package id passed as the - * second param (a system chooser will be opened if there are multiple markets and no default) - * and falls back to Google Play Store web URL if no app to handle the market scheme was found. - *

- * It uses {@link #openIntentInApp(Context, Intent)} to open market scheme and {@link - * #openUrlInBrowser(Context, String)} to open Google Play Store web URL. - * - * @param context the context to use - * @param packageId the package id of the app to be installed - */ - public static void installApp(@NonNull final Context context, final String packageId) { - // Try market scheme - final Intent marketSchemeIntent = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=" + packageId)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (!tryOpenIntentInApp(context, marketSchemeIntent)) { - // Fall back to Google Play Store Web URL (F-Droid can handle it) - openUrlInApp(context, "https://play.google.com/store/apps/details?id=" + packageId); - } - } - - /** - * Open the url with the system default browser. If no browser is installed, falls back to - * {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available - * to handle the action, or possible OEM-related edge cases). - *

- * This function selects the package to open based on which apps respond to the {@code http://} - * schema alone, which should exclude special non-browser apps that are can handle the url (e.g. - * the official YouTube app). - *

- * Therefore please prefer {@link #openUrlInApp(Context, String)}, that handles package - * resolution in a standard way, unless this is the action of an explicit "Open in browser" - * button. - * - * @param context the context to use - * @param url the url to browse - **/ - public static void openUrlInBrowser(@NonNull final Context context, final String url) { - // Target a generic http://, so we are sure to get a browser and not e.g. the yt app. - // Note that this requires the `http` schema to be added to `` in the manifest. - final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); - - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - // See https://stackoverflow.com/a/58801285 and `setSelector` documentation - intent.setSelector(browserIntent); - try { - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // No browser is available. This should, in the end, yield a nice AOSP error message - // indicating that no app is available to handle this action. - // - // Note: there are some situations where modified OEM ROMs have apps that appear - // to be browsers but are actually app choosers. If starting the Activity fails - // related to this, opening the system app chooser is still the correct behavior. - intent.setSelector(null); - openAppChooser(context, intent, true); - } - } - - /** - * Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in - * case of failure. - * - * @param context the context to use - * @param url the url to open - */ - public static void openUrlInApp(@NonNull final Context context, final String url) { - openIntentInApp(context, new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } - - /** - * Open an intent with the system default app. - *

- * Use {@link #openIntentInApp(Context, Intent)} to show a toast in case of failure. - * - * @param context the context to use - * @param intent the intent to open - * @return true if the intent could be opened successfully, false otherwise - */ - public static boolean tryOpenIntentInApp(@NonNull final Context context, - @NonNull final Intent intent) { - try { - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - return false; - } - return true; - } - - /** - * Open an intent with the system default app, showing a toast in case of failure. - *

- * Use {@link #tryOpenIntentInApp(Context, Intent)} if you don't want the toast. Use {@link - * #openUrlInApp(Context, String)} as a shorthand for {@link Intent#ACTION_VIEW} with urls. - * - * @param context the context to use - * @param intent the intent to - */ - public static void openIntentInApp(@NonNull final Context context, - @NonNull final Intent intent) { - if (!tryOpenIntentInApp(context, intent)) { - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) - .show(); - } - } - - /** - * Open the system chooser to launch an intent. - *

- * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted - * as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be - * set as the title of the system chooser. - * For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system - * choosers must be set on this intent, not on the - * {@link android.content.Intent#ACTION_CHOOSER} intent. - * - * @param context the context to use - * @param intent the intent to open - * @param setTitleChooser set the title "Open with" to the chooser if true, else not - */ - private static void openAppChooser(@NonNull final Context context, - @NonNull final Intent intent, - final boolean setTitleChooser) { - final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (setTitleChooser) { - chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); - } - - // Avoid opening in NewPipe - // (Implementation note: if the URL is one for which NewPipe itself - // is set as handler on Android >= 12, we actually remove the only eligible app - // for this link, and browsers will not be offered to the user. For that, use - // `openUrlInBrowser`.) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - chooserIntent.putExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, - new ComponentName[]{new ComponentName(context, RouterActivity.class)} - ); - } - - // Migrate any clip data and flags from the original intent. - final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); - if (permFlags != 0) { - ClipData targetClipData = intent.getClipData(); - if (targetClipData == null && intent.getData() != null) { - final ClipData.Item item = new ClipData.Item(intent.getData()); - final String[] mimeTypes; - if (intent.getType() != null) { - mimeTypes = new String[] {intent.getType()}; - } else { - mimeTypes = new String[] {}; - } - targetClipData = new ClipData(null, mimeTypes, item); - } - if (targetClipData != null) { - chooserIntent.setClipData(targetClipData); - chooserIntent.addFlags(permFlags); - } - } - - try { - context.startActivity(chooserIntent); - } catch (final ActivityNotFoundException e) { - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - } - } - - /** - * Open the android share sheet to share a content. - * - *

- * For Android 10+ users, a content preview is shown, which includes the title of the shared - * content and an image preview the content, if its URL is not null or empty and its - * corresponding image is in the image cache. - *

- * - * @param context the context to use - * @param title the title of the content - * @param content the content to share - * @param imagePreviewUrl the image of the subject - */ - public static void shareText(@NonNull final Context context, - @NonNull final String title, - final String content, - final String imagePreviewUrl) { - final Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_TEXT, content); - if (!TextUtils.isEmpty(title)) { - shareIntent.putExtra(Intent.EXTRA_TITLE, title); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); - } - - // Content preview in the share sheet has been added in Android 10, so it's not needed to - // set a content preview which will be never displayed - // See https://developer.android.com/training/sharing/send#adding-rich-content-previews - // If loading of images has been disabled, don't try to generate a content preview - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && !TextUtils.isEmpty(imagePreviewUrl) - && ImageStrategy.shouldLoadImages()) { - - final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl); - if (clipData != null) { - shareIntent.setClipData(clipData); - shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - openAppChooser(context, shareIntent, false); - } - - /** - * Open the android share sheet to share a content. - * - *

- * For Android 10+ users, a content preview is shown, which includes the title of the shared - * content and an image preview the content, if the preferred image chosen by {@link - * ImageStrategy#choosePreferredImage(List)} is in the image cache. - *

- * - * @param context the context to use - * @param title the title of the content - * @param content the content to share - * @param images a set of possible {@link Image}s of the subject, among which to choose with - * {@link ImageStrategy#choosePreferredImage(List)} since that's likely to - * provide an image that is in Coil's cache - */ - public static void shareText(@NonNull final Context context, - @NonNull final String title, - final String content, - final List images) { - shareText(context, title, content, ImageStrategy.choosePreferredImage(images)); - } - - /** - * Open the android share sheet to share a content. - * - *

- * This calls {@link #shareText(Context, String, String, String)} with an empty string for the - * {@code imagePreviewUrl} parameter. This method should be used when the shared content has no - * preview thumbnail. - *

- * - * @param context the context to use - * @param title the title of the content - * @param content the content to share - */ - public static void shareText(@NonNull final Context context, - @NonNull final String title, - final String content) { - shareText(context, title, content, ""); - } - - /** - * Copy the text to clipboard, and indicate to the user whether the operation was completed - * successfully using a Toast. - * - * @param context the context to use - * @param text the text to copy - */ - public static void copyToClipboard(@NonNull final Context context, final String text) { - final ClipboardManager clipboardManager = - ContextCompat.getSystemService(context, ClipboardManager.class); - - if (clipboardManager == null) { - Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); - return; - } - - try { - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - if (Build.VERSION.SDK_INT < 33) { - // Android 13 has its own "copied to clipboard" dialog - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); - } - } catch (final Exception e) { - Log.e(TAG, "Error when trying to copy text to clipboard", e); - Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); - } - } - - /** - * Generate a {@link ClipData} with the image of the content shared, if it's in the app cache. - * - *

- * In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) - * when sharing a content, only images in the {@link MemoryCache} or {@link DiskCache} - * used by the Coil library are used as preview images. If the thumbnail image is not in the - * cache, no {@link ClipData} will be generated and {@code null} will be returned. - * - *

- * In order to display the image in the content preview of the Android share sheet, an URI of - * the content, accessible and readable by other apps has to be generated, so a new file inside - * the application cache will be generated, named {@code android_share_sheet_image_preview.jpg} - * (if a file under this name already exists, it will be overwritten). The thumbnail will be - * compressed in JPEG format, with a {@code 90} compression level. - *

- * - *

- * Note that if an exception occurs when generating the {@link ClipData}, {@code null} is - * returned. - *

- * - *

- * Using the result of this method when sharing has only an effect on the system share sheet (if - * OEMs didn't change Android system standard behavior) on Android API 29 and higher. - *

- * - * @param context the context to use - * @param thumbnailUrl the URL of the content thumbnail - * @return a {@link ClipData} of the content thumbnail, or {@code null} - */ - @Nullable - private static ClipData generateClipDataForImagePreview( - @NonNull final Context context, - @NonNull final String thumbnailUrl) { - try { - // Save the image in memory to the application's cache because we need a URI to the - // image to generate a ClipData which will show the share sheet, and so an image file - final Context applicationContext = context.getApplicationContext(); - final var loader = SingletonImageLoader.get(context); - final var value = loader.getMemoryCache() - .get(new MemoryCache.Key(thumbnailUrl, Collections.emptyMap())); - - final Bitmap cachedBitmap; - if (value != null) { - cachedBitmap = toBitmap(value.getImage()); - } else { - try (var snapshot = loader.getDiskCache().openSnapshot(thumbnailUrl)) { - if (snapshot != null) { - cachedBitmap = BitmapFactory.decodeFile(snapshot.getData().toString()); - } else { - cachedBitmap = null; - } - } - } - - if (cachedBitmap == null) { - return null; - } - - final var path = applicationContext.getCacheDir().toPath() - .resolve("android_share_sheet_image_preview.jpg"); - // Any existing file will be overwritten - try (var outputStream = Files.newOutputStream(path)) { - cachedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream); - } - - final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", - FileProvider.getUriForFile(applicationContext, - BuildConfig.APPLICATION_ID + ".provider", - path.toFile())); - - if (DEBUG) { - Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); - } - return clipData; - } catch (final Exception e) { - Log.w(TAG, "Error when setting preview image for share sheet", e); - return null; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt deleted file mode 100644 index bd1c57f98..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt +++ /dev/null @@ -1,185 +0,0 @@ -package org.schabi.newpipe.util.image - -import android.content.Context -import android.graphics.Bitmap -import android.util.Log -import android.widget.ImageView -import androidx.annotation.DrawableRes -import coil3.executeBlocking -import coil3.imageLoader -import coil3.request.Disposable -import coil3.request.ImageRequest -import coil3.request.error -import coil3.request.placeholder -import coil3.request.target -import coil3.request.transformations -import coil3.size.Size -import coil3.target.Target -import coil3.toBitmap -import coil3.transform.Transformation -import kotlin.math.min -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.Image -import org.schabi.newpipe.ktx.scale - -object CoilHelper { - private val TAG = CoilHelper::class.java.simpleName - - @JvmOverloads - fun loadBitmapBlocking( - context: Context, - url: String?, - @DrawableRes placeholderResId: Int = 0 - ): Bitmap? = context.imageLoader - .executeBlocking(getImageRequest(context, url, placeholderResId).build()) - .image - ?.toBitmap() - - fun loadAvatar( - target: ImageView, - images: List - ) { - loadImageDefault(target, images, R.drawable.placeholder_person) - } - - fun loadAvatar( - target: ImageView, - url: String? - ) { - loadImageDefault(target, url, R.drawable.placeholder_person) - } - - fun loadThumbnail( - target: ImageView, - images: List - ) { - loadImageDefault(target, images, R.drawable.placeholder_thumbnail_video) - } - - fun loadThumbnail( - target: ImageView, - url: String? - ) { - loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video) - } - - fun loadScaledDownThumbnail( - context: Context, - images: List, - target: Target - ): Disposable { - val url = ImageStrategy.choosePreferredImage(images) - val request = - getImageRequest(context, url, R.drawable.placeholder_thumbnail_video) - .target(target) - .transformations( - object : Transformation() { - override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY" - - override suspend fun transform( - input: Bitmap, - size: Size - ): Bitmap { - if (MainActivity.DEBUG) { - Log.d(TAG, "Thumbnail - transform() called") - } - - val notificationThumbnailWidth = - min( - context.resources.getDimension(R.dimen.player_notification_thumbnail_width), - input.width.toFloat() - ).toInt() - - var newHeight = input.height / (input.width / notificationThumbnailWidth) - val result = input.scale(notificationThumbnailWidth, newHeight) - - return if (result == input || !result.isMutable) { - // create a new mutable bitmap to prevent strange crashes on some - // devices (see #4638) - newHeight = input.height / (input.width / (notificationThumbnailWidth - 1)) - input.scale(notificationThumbnailWidth, newHeight) - } else { - result - } - } - } - ).build() - - return context.imageLoader.enqueue(request) - } - - fun loadDetailsThumbnail( - target: ImageView, - images: List - ) { - val url = ImageStrategy.choosePreferredImage(images) - loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video, false) - } - - fun loadBanner( - target: ImageView, - images: List - ) { - loadImageDefault(target, images, R.drawable.placeholder_channel_banner) - } - - fun loadPlaylistThumbnail( - target: ImageView, - images: List - ) { - loadImageDefault(target, images, R.drawable.placeholder_thumbnail_playlist) - } - - fun loadPlaylistThumbnail( - target: ImageView, - url: String? - ) { - loadImageDefault(target, url, R.drawable.placeholder_thumbnail_playlist) - } - - private fun loadImageDefault( - target: ImageView, - images: List, - @DrawableRes placeholderResId: Int - ) { - loadImageDefault(target, ImageStrategy.choosePreferredImage(images), placeholderResId) - } - - private fun loadImageDefault( - target: ImageView, - url: String?, - @DrawableRes placeholderResId: Int, - showPlaceholder: Boolean = true - ) { - val request = - getImageRequest(target.context, url, placeholderResId, showPlaceholder) - .target(target) - .build() - target.context.imageLoader.enqueue(request) - } - - private fun getImageRequest( - context: Context, - url: String?, - @DrawableRes placeholderResId: Int, - showPlaceholderWhileLoading: Boolean = true - ): ImageRequest.Builder { - // if the URL was chosen with `choosePreferredImage` it will be null, but check again - // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case - // for URLs stored in the database) - val takenUrl = url?.takeIf { it.isNotEmpty() && ImageStrategy.shouldLoadImages() } - - return ImageRequest - .Builder(context) - .data(takenUrl) - .error(placeholderResId) - .memoryCacheKey(takenUrl) - .diskCacheKey(takenUrl) - .apply { - if (takenUrl != null || showPlaceholderWhileLoading) { - placeholder(placeholderResId) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt deleted file mode 100644 index d9d7a3c07..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util.image - -import kotlin.math.abs -import org.schabi.newpipe.extractor.Image -import org.schabi.newpipe.extractor.Image.ResolutionLevel - -object ImageStrategy { - // when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred - // image quality is to these values (H stands for "Height") - private const val BEST_LOW_H = 75 - private const val BEST_MEDIUM_H = 250 - - private var preferredImageQuality = PreferredImageQuality.MEDIUM - - @JvmStatic - fun setPreferredImageQuality(preferredImageQuality: PreferredImageQuality) { - ImageStrategy.preferredImageQuality = preferredImageQuality - } - - @JvmStatic - fun shouldLoadImages(): Boolean { - return preferredImageQuality != PreferredImageQuality.NONE - } - - @JvmStatic - fun estimatePixelCount(image: Image, widthOverHeight: Double): Double { - if (image.height == Image.HEIGHT_UNKNOWN) { - if (image.width == Image.WIDTH_UNKNOWN) { - // images whose size is completely unknown will be in their own subgroups, so - // any one of them will do, hence returning the same value for all of them - return 0.0 - } else { - return image.width * image.width / widthOverHeight - } - } else if (image.width == Image.WIDTH_UNKNOWN) { - return image.height * image.height * widthOverHeight - } else { - return (image.height * image.width).toDouble() - } - } - - /** - * [choosePreferredImage] contains the description for this function's logic. - * - * @param images the images from which to choose - * @param nonNoneQuality the preferred quality (must NOT be [PreferredImageQuality.NONE]) - * @return the chosen preferred image, or `null` if the list is empty - * @see [choosePreferredImage] - */ - @JvmStatic - fun choosePreferredImage(images: List, nonNoneQuality: PreferredImageQuality): String? { - // this will be used to estimate the pixel count for images where only one of height or - // width are known - val widthOverHeight = images - .filter { image -> - image.height != Image.HEIGHT_UNKNOWN && image.width != Image.WIDTH_UNKNOWN - } - .map { image -> (image.width.toDouble()) / image.height } - .elementAtOrNull(0) ?: 1.0 - - val preferredLevel = nonNoneQuality.toResolutionLevel() - // TODO: rewrite using kotlin collections API `groupBy` will be handy - val initialComparator = - Comparator // the first step splits the images into groups of resolution levels - .comparingInt { i: Image -> - return@comparingInt when (i.estimatedResolutionLevel) { - // avoid unknowns as much as possible - ResolutionLevel.UNKNOWN -> 3 - - // prefer a matching resolution level - preferredLevel -> 0 - - // the preferredLevel is only 1 "step" away (either HIGH or LOW) - ResolutionLevel.MEDIUM -> 1 - - // the preferredLevel is the furthest away possible (2 "steps") - else -> 2 - } - } - // then each level's group is further split into two subgroups, one with known image - // size (which is also the preferred subgroup) and the other without - .thenComparing { image -> image.height == Image.HEIGHT_UNKNOWN && image.width == Image.WIDTH_UNKNOWN } - - // The third step chooses, within each subgroup with known image size, the best image based - // on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups - // without known image size will be left untouched since estimatePixelCount always returns - // the same number for those. - val finalComparator = when (nonNoneQuality) { - PreferredImageQuality.NONE -> initialComparator - - PreferredImageQuality.LOW -> initialComparator.thenComparingDouble { image -> - val pixelCount = estimatePixelCount(image, widthOverHeight) - abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight) - } - - PreferredImageQuality.MEDIUM -> initialComparator.thenComparingDouble { image -> - val pixelCount = estimatePixelCount(image, widthOverHeight) - abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight) - } - - PreferredImageQuality.HIGH -> initialComparator.thenComparingDouble { image -> - // this is reversed with a - so that the highest resolution is chosen - -estimatePixelCount(image, widthOverHeight) - } - } - - return images.stream() // using "min" basically means "take the first group, then take the first subgroup, - // then choose the best image, while ignoring all other groups and subgroups" - .min(finalComparator) - .map(Image::getUrl) - .orElse(null) - } - - /** - * Chooses an image amongst the provided list based on the user preference previously set with - * [setPreferredImageQuality]. `null` will be returned in - * case the list is empty or the user preference is to not show images. - *
- * These properties will be preferred, from most to least important: - * - * 1. The image's [Image.estimatedResolutionLevel] is not unknown and is close to [preferredImageQuality] - * 2. At least one of the image's width or height are known - * 3. The highest resolution image is finally chosen if the user's preference is - * [PreferredImageQuality.HIGH], otherwise the chosen image is the one that has the height - * closest to [BEST_LOW_H] or [BEST_MEDIUM_H] - * - *
- * Use [imageListToDbUrl] if the URL is going to be saved to the database, to avoid - * saving nothing in case at the moment of saving the user preference is to not show images. - * - * @param images the images from which to choose - * @return the chosen preferred image, or `null` if the list is empty or the user disabled - * images - * @see [imageListToDbUrl] - */ - @JvmStatic - fun choosePreferredImage(images: List): String? { - if (preferredImageQuality == PreferredImageQuality.NONE) { - return null // do not load images - } - - return choosePreferredImage(images, preferredImageQuality) - } - - /** - * Like [choosePreferredImage], except that if [preferredImageQuality] is - * [PreferredImageQuality.NONE] an image will be chosen anyway (with preferred quality - * [PreferredImageQuality.MEDIUM]. - *

- * To go back to a list of images (obviously with just the one chosen image) from a URL saved in - * the database use [dbUrlToImageList]. - * - * @param images the images from which to choose - * @return the chosen preferred image, or `null` if the list is empty - * @see [choosePreferredImage] - * @see [dbUrlToImageList] - */ - @JvmStatic - fun imageListToDbUrl(images: List): String? { - val quality = when (preferredImageQuality) { - PreferredImageQuality.NONE -> PreferredImageQuality.MEDIUM - else -> preferredImageQuality - } - - return choosePreferredImage(images, quality) - } - - /** - * Wraps the URL (coming from the database) in a `List` so that it is usable - * seamlessly in all of the places where the extractor would return a list of images, including - * allowing to build info objects based on database objects. - *

- * To obtain a url to save to the database from a list of images use [imageListToDbUrl]. - * - * @param url the URL to wrap coming from the database, or `null` to get an empty list - * @return a list containing just one [Image] wrapping the provided URL, with unknown - * image size fields, or an empty list if the URL is `null` - * @see [imageListToDbUrl] - */ - @JvmStatic - fun dbUrlToImageList(url: String?): List { - return when (url) { - null -> listOf() - - else -> listOf( - Image( - url, - Image.HEIGHT_UNKNOWN, - Image.WIDTH_UNKNOWN, - ResolutionLevel.UNKNOWN - ) - ) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt deleted file mode 100644 index b90ba87aa..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util.image - -import android.content.Context -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.Image.ResolutionLevel - -enum class PreferredImageQuality { - NONE, - LOW, - MEDIUM, - HIGH; - - fun toResolutionLevel(): ResolutionLevel { - return when (this) { - LOW -> ResolutionLevel.LOW - MEDIUM -> ResolutionLevel.MEDIUM - HIGH -> ResolutionLevel.HIGH - NONE -> ResolutionLevel.UNKNOWN - } - } - - companion object { - @JvmStatic - fun fromPreferenceKey(context: Context, key: String?): PreferredImageQuality { - return when (key) { - context.getString(R.string.image_quality_none_key) -> NONE - context.getString(R.string.image_quality_low_key) -> LOW - context.getString(R.string.image_quality_high_key) -> HIGH - else -> MEDIUM // default to medium - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/potoken/JavaScriptUtil.kt b/app/src/main/java/org/schabi/newpipe/util/potoken/JavaScriptUtil.kt deleted file mode 100644 index 06740a00e..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/potoken/JavaScriptUtil.kt +++ /dev/null @@ -1,113 +0,0 @@ -package org.schabi.newpipe.util.potoken - -import com.grack.nanojson.JsonObject -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonWriter -import okio.ByteString.Companion.decodeBase64 -import okio.ByteString.Companion.toByteString - -/** - * Parses the raw challenge data obtained from the Create endpoint and returns an object that can be - * embedded in a JavaScript snippet. - */ -fun parseChallengeData(rawChallengeData: String): String { - val scrambled = JsonParser.array().from(rawChallengeData) - - val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) { - val descrambled = descramble(scrambled.getString(1)) - JsonParser.array().from(descrambled) - } else { - scrambled.getArray(0) - } - - val messageId = challengeData.getString(0) - val interpreterHash = challengeData.getString(3) - val program = challengeData.getString(4) - val globalName = challengeData.getString(5) - val clientExperimentsStateBlob = challengeData.getString(7) - - val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String } - val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String } - - return JsonWriter.string( - JsonObject.builder() - .value("messageId", messageId) - .`object`("interpreterJavascript") - .value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue) - .value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue) - .end() - .value("interpreterHash", interpreterHash) - .value("program", program) - .value("globalName", globalName) - .value("clientExperimentsStateBlob", clientExperimentsStateBlob) - .done() - ) -} - -/** - * Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript - * `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the - * duration of this token in seconds. - */ -fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair { - val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData) - return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1) -} - -/** - * Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript - * `Uint8Array` that can be embedded directly in JavaScript code. - */ -fun stringToU8(identifier: String): String { - return newUint8Array(identifier.toByteArray()) -} - -/** - * Takes a poToken encoded as a sequence of bytes represented as integers separated by commas - * (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript, - * and converts it to the specific base64 representation for poTokens. - */ -fun u8ToBase64(poToken: String): String { - return poToken.split(",") - .map { it.toUByte().toByte() } - .toByteArray() - .toByteString() - .base64() - .replace("+", "-") - .replace("/", "_") -} - -/** - * Takes the scrambled challenge, decodes it from base64, adds 97 to each byte. - */ -private fun descramble(scrambledChallenge: String): String { - return base64ToByteString(scrambledChallenge) - .map { (it + 97).toByte() } - .toByteArray() - .decodeToString() -} - -/** - * Decodes a base64 string encoded in the specific base64 representation used by YouTube, and - * returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code. - */ -private fun base64ToU8(base64: String): String { - return newUint8Array(base64ToByteString(base64)) -} - -private fun newUint8Array(contents: ByteArray): String { - return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])" -} - -/** - * Decodes a base64 string encoded in the specific base64 representation used by YouTube. - */ -private fun base64ToByteString(base64: String): ByteArray { - val base64Mod = base64 - .replace('-', '+') - .replace('_', '/') - .replace('.', '=') - - return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode")) - .toByteArray() -} diff --git a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenException.kt b/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenException.kt deleted file mode 100644 index 683fd48b2..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenException.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.util.potoken - -class PoTokenException(message: String) : Exception(message) - -// to be thrown if the WebView provided by the system is broken -class BadWebViewException(message: String) : Exception(message) - -fun buildExceptionForJsError(error: String): Exception { - return if (error.contains("SyntaxError")) { - BadWebViewException(error) - } else { - PoTokenException(error) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenGenerator.kt b/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenGenerator.kt deleted file mode 100644 index 6446ecc72..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenGenerator.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.schabi.newpipe.util.potoken - -import android.content.Context -import io.reactivex.rxjava3.core.Single -import java.io.Closeable - -/** - * This interface was created to allow for multiple methods to generate poTokens in the future (e.g. - * via WebView and via a local DOM implementation) - */ -interface PoTokenGenerator : Closeable { - /** - * Generates a poToken for the provided identifier, using the `integrityToken` and - * `webPoSignalOutput` previously obtained in the initialization of [PoTokenWebView]. Can be - * called multiple times. - */ - fun generatePoToken(identifier: String): Single - - /** - * @return whether the `integrityToken` is expired, in which case all tokens generated by - * [generatePoToken] will be invalid - */ - fun isExpired(): Boolean - - interface Factory { - /** - * Initializes a [PoTokenGenerator] by loading the BotGuard VM, running it, and obtaining - * an `integrityToken`. Can then be used multiple times to generate multiple poTokens with - * [generatePoToken]. - * - * @param context used e.g. to load the HTML asset or to instantiate a WebView - */ - fun newPoTokenGenerator(context: Context): Single - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenProviderImpl.kt b/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenProviderImpl.kt deleted file mode 100644 index 53ae04a3c..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenProviderImpl.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.schabi.newpipe.util.potoken - -import android.os.Handler -import android.os.Looper -import android.util.Log -import org.schabi.newpipe.App -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo -import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider -import org.schabi.newpipe.extractor.services.youtube.PoTokenResult -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper -import org.schabi.newpipe.util.DeviceUtils - -object PoTokenProviderImpl : PoTokenProvider { - val TAG = PoTokenProviderImpl::class.simpleName - private val webViewSupported by lazy { DeviceUtils.supportsWebView() } - private var webViewBadImpl = false // whether the system has a bad WebView implementation - - private object WebPoTokenGenLock - private var webPoTokenVisitorData: String? = null - private var webPoTokenStreamingPot: String? = null - private var webPoTokenGenerator: PoTokenGenerator? = null - - override fun getWebClientPoToken(videoId: String): PoTokenResult? { - if (!webViewSupported || webViewBadImpl) { - return null - } - - try { - return getWebClientPoToken(videoId = videoId, forceRecreate = false) - } catch (e: RuntimeException) { - // RxJava's Single wraps exceptions into RuntimeErrors, so we need to unwrap them here - when (val cause = e.cause) { - is BadWebViewException -> { - Log.e(TAG, "Could not obtain poToken because WebView is broken", e) - webViewBadImpl = true - return null - } - - null -> throw e - - else -> throw cause // includes PoTokenException - } - } - } - - /** - * @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in - * case the current [webPoTokenGenerator] threw an error last time - * [PoTokenGenerator.generatePoToken] was called - */ - private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult { - // just a helper class since Kotlin does not have builtin support for 4-tuples - data class Quadruple(val t1: T1, val t2: T2, val t3: T3, val t4: T4) - - val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) = - synchronized(WebPoTokenGenLock) { - val shouldRecreate = webPoTokenGenerator == null || forceRecreate || - webPoTokenGenerator!!.isExpired() - - if (shouldRecreate) { - val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient() - innertubeClientRequestInfo.clientInfo.clientVersion = - YoutubeParsingHelper.getClientVersion() - - webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube( - innertubeClientRequestInfo, - NewPipe.getPreferredLocalization(), - NewPipe.getPreferredContentCountry(), - YoutubeParsingHelper.getYouTubeHeaders(), - YoutubeParsingHelper.YOUTUBEI_V1_URL, - null, - false - ) - // close the current webPoTokenGenerator on the main thread - webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } } - - // create a new webPoTokenGenerator - webPoTokenGenerator = PoTokenWebView - .newPoTokenGenerator(App.instance).blockingGet() - - // The streaming poToken needs to be generated exactly once before generating - // any other (player) tokens. - webPoTokenStreamingPot = webPoTokenGenerator!! - .generatePoToken(webPoTokenVisitorData!!).blockingGet() - } - - return@synchronized Quadruple( - webPoTokenGenerator!!, - webPoTokenVisitorData!!, - webPoTokenStreamingPot!!, - shouldRecreate - ) - } - - val playerPot = try { - // Not using synchronized here, since poTokenGenerator would be able to generate - // multiple poTokens in parallel if needed. The only important thing is for exactly one - // visitorData/streaming poToken to be generated before anything else. - poTokenGenerator.generatePoToken(videoId).blockingGet() - } catch (throwable: Throwable) { - if (hasBeenRecreated) { - // the poTokenGenerator has just been recreated (and possibly this is already the - // second time we try), so there is likely nothing we can do - throw throwable - } else { - // retry, this time recreating the [webPoTokenGenerator] from scratch; - // this might happen for example if NewPipe goes in the background and the WebView - // content is lost - Log.e(TAG, "Failed to obtain poToken, retrying", throwable) - return getWebClientPoToken(videoId = videoId, forceRecreate = true) - } - } - - if (BuildConfig.DEBUG) { - Log.d( - TAG, - "poToken for $videoId: playerPot=$playerPot, " + - "streamingPot=$streamingPot, visitor_data=$visitorData" - ) - } - - return PoTokenResult(visitorData, playerPot, streamingPot) - } - - override fun getWebEmbedClientPoToken(videoId: String): PoTokenResult? = null - - override fun getAndroidClientPoToken(videoId: String): PoTokenResult? = null - - override fun getIosClientPoToken(videoId: String): PoTokenResult? = null -} diff --git a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenWebView.kt b/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenWebView.kt deleted file mode 100644 index deeef613a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenWebView.kt +++ /dev/null @@ -1,396 +0,0 @@ -package org.schabi.newpipe.util.potoken - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.webkit.ConsoleMessage -import android.webkit.JavascriptInterface -import android.webkit.WebChromeClient -import android.webkit.WebView -import androidx.annotation.MainThread -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.core.SingleEmitter -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers -import java.time.Instant -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.DownloaderImpl - -class PoTokenWebView private constructor( - context: Context, - // to be used exactly once only during initialization! - private val generatorEmitter: SingleEmitter -) : PoTokenGenerator { - private val webView = WebView(context) - private val disposables = CompositeDisposable() // used only during initialization - private val poTokenEmitters = mutableListOf>>() - private lateinit var expirationInstant: Instant - - //region Initialization - init { - val webViewSettings = webView.settings - //noinspection SetJavaScriptEnabled we want to use JavaScript! - webViewSettings.javaScriptEnabled = true - if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { - WebSettingsCompat.setSafeBrowsingEnabled(webViewSettings, false) - } - webViewSettings.userAgentString = USER_AGENT - webViewSettings.blockNetworkLoads = true // the WebView does not need internet access - - // so that we can run async functions and get back the result - webView.addJavascriptInterface(this, JS_INTERFACE) - - webView.webChromeClient = object : WebChromeClient() { - override fun onConsoleMessage(m: ConsoleMessage): Boolean { - if (m.message().contains("Uncaught")) { - // There should not be any uncaught errors while executing the code, because - // everything that can fail is guarded by try-catch. Therefore, this likely - // indicates that there was a syntax error in the code, i.e. the WebView only - // supports a really old version of JS. - - val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})" - val exception = BadWebViewException(fmt) - Log.e(TAG, "This WebView implementation is broken: $fmt") - - onInitializationErrorCloseAndCancel(exception) - popAllPoTokenEmitters().forEach { (_, emitter) -> emitter.onError(exception) } - } - return super.onConsoleMessage(m) - } - } - } - - /** - * Must be called right after instantiating [PoTokenWebView] to perform the actual - * initialization. This will asynchronously go through all the steps needed to load BotGuard, - * run it, and obtain an `integrityToken`. - */ - private fun loadHtmlAndObtainBotguard(context: Context) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "loadHtmlAndObtainBotguard() called") - } - - disposables.add( - Single.fromCallable { - val html = context.assets.open("po_token.html").bufferedReader() - .use { it.readText() } - return@fromCallable html - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { html -> - webView.loadDataWithBaseURL( - "https://www.youtube.com", - html.replaceFirst( - "", - // calls downloadAndRunBotguard() when the page has finished loading - "\n$JS_INTERFACE.downloadAndRunBotguard()" - ), - "text/html", - "utf-8", - null - ) - }, - this::onInitializationErrorCloseAndCancel - ) - ) - } - - /** - * Called during initialization by the JavaScript snippet appended to the HTML page content in - * [loadHtmlAndObtainBotguard] after the WebView content has been loaded. - */ - @JavascriptInterface - fun downloadAndRunBotguard() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "downloadAndRunBotguard() called") - } - - makeBotguardServiceRequest( - "https://www.youtube.com/api/jnn/v1/Create", - "[ \"$REQUEST_KEY\" ]" - ) { responseBody -> - val parsedChallengeData = parseChallengeData(responseBody) - webView.evaluateJavascript( - """try { - data = $parsedChallengeData - runBotGuard(data).then(function (result) { - this.webPoSignalOutput = result.webPoSignalOutput - $JS_INTERFACE.onRunBotguardResult(result.botguardResponse) - }, function (error) { - $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) - }) - } catch (error) { - $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) - }""", - null - ) - } - } - - /** - * Called during initialization by the JavaScript snippets from either - * [downloadAndRunBotguard] or [onRunBotguardResult]. - */ - @JavascriptInterface - fun onJsInitializationError(error: String) { - if (BuildConfig.DEBUG) { - Log.e(TAG, "Initialization error from JavaScript: $error") - } - onInitializationErrorCloseAndCancel(buildExceptionForJsError(error)) - } - - /** - * Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after - * obtaining the BotGuard execution output [botguardResponse]. - */ - @JavascriptInterface - fun onRunBotguardResult(botguardResponse: String) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "botguardResponse: $botguardResponse") - } - makeBotguardServiceRequest( - "https://www.youtube.com/api/jnn/v1/GenerateIT", - "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]" - ) { responseBody -> - if (BuildConfig.DEBUG) { - Log.d(TAG, "GenerateIT response: $responseBody") - } - val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody) - - // leave 10 minutes of margin just to be sure - expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600) - - webView.evaluateJavascript( - "this.integrityToken = $integrityToken" - ) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s") - } - generatorEmitter.onSuccess(this) - } - } - } - //endregion - - //region Obtaining poTokens - override fun generatePoToken(identifier: String): Single = Single.create { emitter -> - if (BuildConfig.DEBUG) { - Log.d(TAG, "generatePoToken() called with identifier $identifier") - } - runOnMainThread(emitter) { - addPoTokenEmitter(identifier, emitter) - val u8Identifier = stringToU8(identifier) - webView.evaluateJavascript( - """try { - identifier = "$identifier" - u8Identifier = $u8Identifier - poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier) - poTokenU8String = "" - for (i = 0; i < poTokenU8.length; i++) { - if (i != 0) poTokenU8String += "," - poTokenU8String += poTokenU8[i] - } - $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) - } catch (error) { - $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack) - }""" - ) {} - } - } - - /** - * Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the - * JavaScript `obtainPoToken()` function. - */ - @JavascriptInterface - fun onObtainPoTokenError(identifier: String, error: String) { - if (BuildConfig.DEBUG) { - Log.e(TAG, "obtainPoToken error from JavaScript: $error") - } - popPoTokenEmitter(identifier)?.onError(buildExceptionForJsError(error)) - } - - /** - * Called by the JavaScript snippet from [generatePoToken] with the original identifier and the - * result of the JavaScript `obtainPoToken()` function. - */ - @JavascriptInterface - fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8") - } - val poToken = try { - u8ToBase64(poTokenU8) - } catch (t: Throwable) { - popPoTokenEmitter(identifier)?.onError(t) - return - } - - if (BuildConfig.DEBUG) { - Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken") - } - popPoTokenEmitter(identifier)?.onSuccess(poToken) - } - - override fun isExpired(): Boolean { - return Instant.now().isAfter(expirationInstant) - } - //endregion - - //region Handling multiple emitters - - /** - * Adds the ([identifier], [emitter]) pair to the [poTokenEmitters] list. This makes it so that - * multiple poToken requests can be generated invparallel, and the results will be notified to - * the right emitters. - */ - private fun addPoTokenEmitter(identifier: String, emitter: SingleEmitter) { - synchronized(poTokenEmitters) { - poTokenEmitters.add(Pair(identifier, emitter)) - } - } - - /** - * Extracts and removes from the [poTokenEmitters] list a [SingleEmitter] based on its - * [identifier]. The emitter is supposed to be used immediately after to either signal a success - * or an error. - */ - private fun popPoTokenEmitter(identifier: String): SingleEmitter? { - return synchronized(poTokenEmitters) { - poTokenEmitters.indexOfFirst { it.first == identifier }.takeIf { it >= 0 }?.let { - poTokenEmitters.removeAt(it).second - } - } - } - - /** - * Clears [poTokenEmitters] and returns its previous contents. The emitters are supposed to be - * used immediately after to either signal a success or an error. - */ - private fun popAllPoTokenEmitters(): List>> { - return synchronized(poTokenEmitters) { - val result = poTokenEmitters.toList() - poTokenEmitters.clear() - result - } - } - //endregion - - //region Utils - - /** - * Makes a POST request to [url] with the given [data] by setting the correct headers. Calls - * [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response - * does not have HTTP code 200, therefore this is supposed to be used only during - * initialization. Calls [handleResponseBody] with the response body if the response is - * successful. The request is performed in the background and a disposable is added to - * [disposables]. - */ - private fun makeBotguardServiceRequest( - url: String, - data: String, - handleResponseBody: (String) -> Unit - ) { - disposables.add( - Single.fromCallable { - return@fromCallable DownloaderImpl.getInstance().post( - url, - mapOf( - // replace the downloader user agent - "User-Agent" to listOf(USER_AGENT), - "Accept" to listOf("application/json"), - "Content-Type" to listOf("application/json+protobuf"), - "x-goog-api-key" to listOf(GOOGLE_API_KEY), - "x-user-agent" to listOf("grpc-web-javascript/0.1") - ), - data.toByteArray() - ) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { response -> - val httpCode = response.responseCode() - if (httpCode != 200) { - onInitializationErrorCloseAndCancel( - PoTokenException("Invalid response code: $httpCode") - ) - return@subscribe - } - val responseBody = response.responseBody() - handleResponseBody(responseBody) - }, - this::onInitializationErrorCloseAndCancel - ) - ) - } - - /** - * Handles any error happening during initialization, releasing resources and sending the error - * to [generatorEmitter]. - */ - private fun onInitializationErrorCloseAndCancel(error: Throwable) { - runOnMainThread(generatorEmitter) { - close() - generatorEmitter.onError(error) - } - } - - /** - * Releases all [webView] and [disposables] resources. - */ - @MainThread - override fun close() { - disposables.dispose() - - webView.clearHistory() - // clears RAM cache and disk cache (globally for all WebViews) - webView.clearCache(true) - - // ensures that the WebView isn't doing anything when destroying it - webView.loadUrl("about:blank") - - webView.onPause() - webView.removeAllViews() - webView.destroy() - } - //endregion - - companion object : PoTokenGenerator.Factory { - private val TAG = PoTokenWebView::class.simpleName - - // Public API key used by BotGuard, which has been got by looking at BotGuard requests - private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" // NOSONAR - private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" - private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3" - private const val JS_INTERFACE = "PoTokenWebView" - - override fun newPoTokenGenerator(context: Context): Single = Single.create { emitter -> - runOnMainThread(emitter) { - val potWv = PoTokenWebView(context, emitter) - potWv.loadHtmlAndObtainBotguard(context) - emitter.setDisposable(potWv.disposables) - } - } - - /** - * Runs [runnable] on the main thread using `Handler(Looper.getMainLooper()).post()`, and - * if the `post` fails emits an error on [emitterIfPostFails]. - */ - private fun runOnMainThread( - emitterIfPostFails: SingleEmitter, - runnable: Runnable - ) { - if (!Handler(Looper.getMainLooper()).post(runnable)) { - emitterIfPostFails.onError(PoTokenException("Could not run on main thread")) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java deleted file mode 100644 index 8a0363ecb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.content.Context; -import android.view.View; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -final class HashtagLongPressClickableSpan extends LongPressClickableSpan { - - @NonNull - private final Context context; - @NonNull - private final String parsedHashtag; - private final int relatedInfoServiceId; - - HashtagLongPressClickableSpan(@NonNull final Context context, - @NonNull final String parsedHashtag, - final int relatedInfoServiceId) { - this.context = context; - this.parsedHashtag = parsedHashtag; - this.relatedInfoServiceId = relatedInfoServiceId; - } - - @Override - public void onClick(@NonNull final View view) { - NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag); - } - - @Override - public void onLongClick(@NonNull final View view) { - ShareUtils.copyToClipboard(context, parsedHashtag); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java deleted file mode 100644 index 3288b4347..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.content.Context; -import android.content.Intent; -import androidx.core.content.ContextCompat; - -import androidx.annotation.NonNull; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; -import org.schabi.newpipe.player.TimestampChangeData; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class InternalUrlsHandler { - private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); - - private InternalUrlsHandler() { - } - - /** - * Handle a YouTube timestamp description URL in NewPipe. - *

- * This method will check if the provided url is a YouTube timestamp description URL ({@code - * https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup - * player will be opened when the user will click on the timestamp in the video description, - * at the time and for the video indicated in the timestamp. - * - * @param context the context to use - * @param url the URL to check if it can be handled - * @return true if the URL can be handled by NewPipe, false if it cannot - */ - public static boolean handleUrlDescriptionTimestamp(final Context context, - @NonNull final String url) { - final Matcher matcher = AMPERSAND_TIMESTAMP_PATTERN.matcher(url); - if (!matcher.matches()) { - return false; - } - final String matchedUrl = matcher.group(1); - final int seconds; - if (matcher.group(2) == null) { - seconds = -1; - } else { - seconds = Integer.parseInt(matcher.group(2)); - } - - final StreamingService service; - final StreamingService.LinkType linkType; - try { - service = NewPipe.getServiceByUrl(matchedUrl); - linkType = service.getLinkTypeByUrl(matchedUrl); - if (linkType == StreamingService.LinkType.NONE) { - return false; - } - } catch (final ExtractionException e) { - return false; - } - - if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { - return playOnPopup(context, matchedUrl, service, seconds); - } else { - NavigationHelper.openRouterActivity(context, matchedUrl); - return true; - } - } - - /** - * Play a content in the floating player. - * - * @param context the context to be used - * @param url the URL of the content - * @param service the service of the content - * @param seconds the position in seconds at which the floating player will start - * @return true if the playback of the content has successfully started or false if not - */ - public static boolean playOnPopup(final Context context, - final String url, - @NonNull final StreamingService service, - final int seconds) { - final LinkHandlerFactory factory = service.getStreamLHFactory(); - final String cleanUrl; - - try { - cleanUrl = factory.getUrl(factory.getId(url)); - } catch (final ParsingException e) { - return false; - } - - final Intent intent = NavigationHelper.getPlayerTimestampIntent(context, - new TimestampChangeData( - service.getServiceId(), - cleanUrl, - seconds - )); - ContextCompat.startForegroundService(context, intent); - - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java deleted file mode 100644 index 5c94a5850..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.text.style.ClickableSpan; -import android.view.View; - -import androidx.annotation.NonNull; - -public abstract class LongPressClickableSpan extends ClickableSpan { - - public abstract void onLongClick(@NonNull View view); - -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java deleted file mode 100644 index bd57621cb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.schabi.newpipe.util.text; - -import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; - -import android.os.Handler; -import android.os.Looper; -import android.text.Selection; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.text.method.MovementMethod; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import android.widget.TextView; - -import androidx.annotation.NonNull; - -// Class adapted from https://stackoverflow.com/a/31786969 - -public class LongPressLinkMovementMethod extends LinkMovementMethod { - - private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout(); - - private static LongPressLinkMovementMethod instance; - - private Handler longClickHandler; - private boolean isLongPressed = false; - - @Override - public boolean onTouchEvent(@NonNull final TextView widget, - @NonNull final Spannable buffer, - @NonNull final MotionEvent event) { - final int action = event.getAction(); - - if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { - longClickHandler.removeCallbacksAndMessages(null); - } - - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { - final int offset = getOffsetForHorizontalLine(widget, event); - final LongPressClickableSpan[] link = buffer.getSpans(offset, offset, - LongPressClickableSpan.class); - - if (link.length != 0) { - if (action == MotionEvent.ACTION_UP) { - if (longClickHandler != null) { - longClickHandler.removeCallbacksAndMessages(null); - } - if (!isLongPressed) { - link[0].onClick(widget); - } - isLongPressed = false; - } else { - Selection.setSelection(buffer, buffer.getSpanStart(link[0]), - buffer.getSpanEnd(link[0])); - if (longClickHandler != null) { - longClickHandler.postDelayed(() -> { - link[0].onLongClick(widget); - isLongPressed = true; - }, LONG_PRESS_TIME); - } - } - return true; - } - } - - return super.onTouchEvent(widget, buffer, event); - } - - public static MovementMethod getInstance() { - if (instance == null) { - instance = new LongPressLinkMovementMethod(); - instance.longClickHandler = new Handler(Looper.myLooper()); - } - - return instance; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java deleted file mode 100644 index 184b73304..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.graphics.Paint; -import android.text.Layout; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; - -import java.util.function.Consumer; - - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -/** - *

Class to ellipsize text inside a {@link TextView}.

- * This class provides all utils to automatically ellipsize and expand a text - */ -public final class TextEllipsizer { - private static final int EXPANDED_LINES = Integer.MAX_VALUE; - private static final String ELLIPSIS = "…"; - - @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); - - @NonNull private final TextView view; - private final int maxLines; - @NonNull private Description content; - @Nullable private StreamingService streamingService; - @Nullable private String streamUrl; - private boolean isEllipsized = false; - @Nullable private Boolean canBeEllipsized = null; - - @NonNull private final Paint paintAtContentSize = new Paint(); - private final float ellipsisWidthPx; - @Nullable private Consumer stateChangeListener = null; - @Nullable private Consumer onContentChanged; - - public TextEllipsizer(@NonNull final TextView view, - final int maxLines, - @Nullable final StreamingService streamingService) { - this.view = view; - this.maxLines = maxLines; - this.content = Description.EMPTY_DESCRIPTION; - this.streamingService = streamingService; - - paintAtContentSize.setTextSize(view.getTextSize()); - ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); - } - - public void setOnContentChanged(@Nullable final Consumer onContentChanged) { - this.onContentChanged = onContentChanged; - } - - public void setContent(@NonNull final Description content) { - this.content = content; - canBeEllipsized = null; - linkifyContentView(v -> { - final int currentMaxLines = view.getMaxLines(); - view.setMaxLines(EXPANDED_LINES); - canBeEllipsized = view.getLineCount() > maxLines; - view.setMaxLines(currentMaxLines); - if (onContentChanged != null) { - onContentChanged.accept(canBeEllipsized); - } - }); - } - - public void setStreamUrl(@Nullable final String streamUrl) { - this.streamUrl = streamUrl; - } - - public void setStreamingService(@NonNull final StreamingService streamingService) { - this.streamingService = streamingService; - } - - /** - * Expand the {@link TextEllipsizer#content} to its full length. - */ - public void expand() { - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> isEllipsized = false); - } - - /** - * Shorten the {@link TextEllipsizer#content} to the given number of - * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' - * if the text was shorted. - */ - public void ellipsize() { - // expand text to see whether it is necessary to ellipsize the text - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> { - final CharSequence charSeqText = view.getText(); - if (charSeqText != null && view.getLineCount() > maxLines) { - // Note that converting to String removes spans (i.e. links), but that's something - // we actually want since when the text is ellipsized we want all clicks on the - // comment to expand the comment, not to open links. - final String text = charSeqText.toString(); - - final Layout layout = view.getLayout(); - final float lineWidth = layout.getLineWidth(maxLines - 1); - final float layoutWidth = layout.getWidth(); - final int lineStart = layout.getLineStart(maxLines - 1); - final int lineEnd = layout.getLineEnd(maxLines - 1); - - // remove characters up until there is enough space for the ellipsis - // (also summing 2 more pixels, just to be sure to avoid float rounding errors) - int end = lineEnd; - float removedCharactersWidth = 0.0f; - while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth - && end >= lineStart) { - end -= 1; - // recalculate each time to account for ligatures or other similar things - removedCharactersWidth = paintAtContentSize.measureText( - text.substring(end, lineEnd)); - } - - // remove trailing spaces and newlines - while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { - end -= 1; - } - - final String newVal = text.substring(0, end) + ELLIPSIS; - view.setText(newVal); - isEllipsized = true; - } else { - isEllipsized = false; - } - view.setMaxLines(maxLines); - }); - } - - /** - * Toggle the view between the ellipsized and expanded state. - */ - public void toggle() { - if (isEllipsized) { - expand(); - } else { - ellipsize(); - } - } - - /** - * Whether the {@link #view} can be ellipsized. - * This is only the case when the {@link #content} has more lines - * than allowed via {@link #maxLines}. - * @return {@code true} if the {@link #content} has more lines than allowed via - * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into - * the {@link #view} without being shortened and {@code null} if the initialization is not - * completed yet. - */ - @Nullable - public Boolean canBeEllipsized() { - return canBeEllipsized; - } - - private void linkifyContentView(final Consumer consumer) { - final boolean oldState = isEllipsized; - disposable.clear(); - TextLinkifier.fromDescription(view, content, - HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, - v -> { - consumer.accept(v); - notifyStateChangeListener(oldState); - }); - - } - - /** - * Add a listener which is called when the given content is changed, - * either from ellipsized to full or vice versa. - * @param listener The listener to be called, or {@code null} to remove it. - * The Boolean parameter is the new state. - * Ellipsized content is represented as {@code true}, - * normal or full content by {@code false}. - */ - public void setStateChangeListener(@Nullable final Consumer listener) { - this.stateChangeListener = listener; - } - - private void notifyStateChangeListener(final boolean oldState) { - if (oldState != isEllipsized && stateChangeListener != null) { - stateChangeListener.accept(isEllipsized); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java deleted file mode 100644 index 4221da398..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java +++ /dev/null @@ -1,369 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.function.Consumer; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.noties.markwon.Markwon; -import io.noties.markwon.linkify.LinkifyPlugin; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class TextLinkifier { - public static final String TAG = TextLinkifier.class.getSimpleName(); - - // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores - private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)"); - - public static final Consumer SET_LINK_MOVEMENT_METHOD = - v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance()); - - private TextLinkifier() { - } - - /** - * Create links for contents with an {@link Description} in the various possible formats. - *

- * This will call one of these three functions based on the format: {@link #fromHtml}, - * {@link #fromMarkdown} or {@link #fromPlainText}. - * - * @param textView the TextView to set the htmlBlock linked - * @param description the htmlBlock to be linked - * @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} - * will be called (not used for formats different than HTML) - * @param relatedInfoService if given, handle hashtags to search for the term in the correct - * service - * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle - * timestamps to open the stream in the popup player at the specific - * time - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - * @param onCompletion will be run when setting text to the textView completes; use {@link - * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable - */ - public static void fromDescription(@NonNull final TextView textView, - @NonNull final Description description, - final int htmlCompatFlag, - @Nullable final StreamingService relatedInfoService, - @Nullable final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { - switch (description.getType()) { - case Description.HTML: - TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, - relatedInfoService, relatedStreamUrl, disposables, onCompletion); - break; - case Description.MARKDOWN: - TextLinkifier.fromMarkdown(textView, description.getContent(), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); - break; - case Description.PLAIN_TEXT: default: - TextLinkifier.fromPlainText(textView, description.getContent(), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); - break; - } - } - - /** - * Create links for contents with an HTML description. - * - *

- * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, - * String, CompositeDisposable, Consumer)} after having linked the URLs with - * {@link HtmlCompat#fromHtml(String, int)}. - *

- * - * @param textView the {@link TextView} to set the HTML string block linked - * @param htmlBlock the HTML string block to be linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, - * int)} will be called - * @param relatedInfoService if given, handle hashtags to search for the term in the correct - * service - * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle - * timestamps to open the stream in the popup player at the specific - * time - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - * @param onCompletion will be run when setting text to the textView completes; use {@link - * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable - */ - public static void fromHtml(@NonNull final TextView textView, - @NonNull final String htmlBlock, - final int htmlCompatFlag, - @Nullable final StreamingService relatedInfoService, - @Nullable final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { - changeLinkIntents( - textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, - relatedStreamUrl, disposables, onCompletion); - } - - /** - * Create links for contents with a plain text description. - * - *

- * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, - * String, CompositeDisposable, Consumer)} after having linked the URLs with - * {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. - *

- * - * @param textView the {@link TextView} to set the plain text block linked - * @param plainTextBlock the block of plain text to be linked - * @param relatedInfoService if given, handle hashtags to search for the term in the correct - * service - * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle - * timestamps to open the stream in the popup player at the specific - * time - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - * @param onCompletion will be run when setting text to the textView completes; use {@link - * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable - */ - public static void fromPlainText(@NonNull final TextView textView, - @NonNull final String plainTextBlock, - @Nullable final StreamingService relatedInfoService, - @Nullable final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { - textView.setAutoLinkMask(Linkify.WEB_URLS); - textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - changeLinkIntents(textView, textView.getText(), relatedInfoService, - relatedStreamUrl, disposables, onCompletion); - } - - /** - * Create links for contents with a markdown description. - * - *

- * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, - * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. - *

- * - * @param textView the {@link TextView} to set the plain text block linked - * @param markdownBlock the block of markdown text to be linked - * @param relatedInfoService if given, handle hashtags to search for the term in the correct - * service - * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle - * timestamps to open the stream in the popup player at the specific - * time - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - * @param onCompletion will be run when setting text to the textView completes; use {@link - * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable - */ - public static void fromMarkdown(@NonNull final TextView textView, - @NonNull final String markdownBlock, - @Nullable final StreamingService relatedInfoService, - @Nullable final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { - final Markwon markwon = Markwon.builder(textView.getContext()) - .usePlugin(LinkifyPlugin.create()).build(); - changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); - } - - /** - * Change links generated by libraries in the description of a content to a custom link action - * and add click listeners on timestamps in this description. - * - *

- * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of - * a content, this method will parse the {@link CharSequence} and replace all current web links - * with {@link ShareUtils#openUrlInBrowser(Context, String)}. - *

- * - *

- * This method will also add click listeners on timestamps in this description, which will play - * the content in the popup player at the time indicated in the timestamp, by using - * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, - * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by - * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, - * StreamingService)}, which will open a search on the current service with the hashtag. - *

- * - *

- * This method is required in order to intercept links and e.g. show a confirmation dialog - * before opening a web link. - *

- * - * @param textView the {@link TextView} to which the converted {@link CharSequence} - * will be applied - * @param chars the {@link CharSequence} to be parsed - * @param relatedInfoService if given, handle hashtags to search for the term in the correct - * service - * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle - * timestamps to open the stream in the popup player at the specific - * time - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - * @param onCompletion will be run when setting text to the textView completes; use {@link - * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable - */ - private static void changeLinkIntents(@NonNull final TextView textView, - @NonNull final CharSequence chars, - @Nullable final StreamingService relatedInfoService, - @Nullable final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { - disposables.add(Single.fromCallable(() -> { - final Context context = textView.getContext(); - - // add custom click actions on web links - final SpannableStringBuilder textBlockLinked = - new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), - URLSpan.class); - - for (final URLSpan span : urls) { - final String url = span.getURL(); - final LongPressClickableSpan longPressClickableSpan = - new UrlLongPressClickableSpan(context, url); - - textBlockLinked.setSpan(longPressClickableSpan, - textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), - textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } - - // add click actions on plain text timestamps only for description of contents, - // unneeded for meta-info or other TextViews - if (relatedInfoService != null) { - if (relatedStreamUrl != null) { - addClickListenersOnTimestamps(context, textBlockLinked, - relatedInfoService, relatedStreamUrl, disposables); - } - addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); - } - - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - textBlockLinked -> - setTextViewCharSequence(textView, textBlockLinked, onCompletion), - throwable -> { - Log.e(TAG, "Unable to linkify text", throwable); - // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars, onCompletion); - })); - } - - /** - * Add click listeners which opens a search on hashtags in a plain text. - * - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens - * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, - * in the service of the content when pressed, and copy the hashtag to clipboard when - * long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}). - *

- * - * @param context the {@link Context} to use - * @param spannableDescription the {@link SpannableStringBuilder} with the text of the - * content description - * @param relatedInfoService used to search for the term in the correct service - */ - private static void addClickListenersOnHashtags( - @NonNull final Context context, - @NonNull final SpannableStringBuilder spannableDescription, - @NonNull final StreamingService relatedInfoService) { - final String descriptionText = spannableDescription.toString(); - final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); - - while (hashtagsMatches.find()) { - final int hashtagStart = hashtagsMatches.start(1); - final int hashtagEnd = hashtagsMatches.end(1); - final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); - - // Don't add a LongPressClickableSpan if there is already one, which should be a part - // of an URL, already parsed before - if (spannableDescription.getSpans(hashtagStart, hashtagEnd, - LongPressClickableSpan.class).length == 0) { - final int serviceId = relatedInfoService.getServiceId(); - spannableDescription.setSpan( - new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId), - hashtagStart, hashtagEnd, 0); - } - } - } - - /** - * Add click listeners which opens the popup player on timestamps in a plain text. - * - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the - * popup player at the time indicated in the timestamps and copy the timestamp in clipboard - * when long-pressed. - *

- * - * @param context the {@link Context} to use - * @param spannableDescription the {@link SpannableStringBuilder} with the text of the - * content description - * @param relatedInfoService the service of the {@code relatedStreamUrl} - * @param relatedStreamUrl what to open in the popup player when timestamps are clicked - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - */ - private static void addClickListenersOnTimestamps( - @NonNull final Context context, - @NonNull final SpannableStringBuilder spannableDescription, - @NonNull final StreamingService relatedInfoService, - @NonNull final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables) { - final String descriptionText = spannableDescription.toString(); - final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( - descriptionText); - - while (timestampsMatches.find()) { - final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = - TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText); - - if (timestampMatchDTO == null) { - continue; - } - - spannableDescription.setSpan( - new TimestampLongPressClickableSpan(context, descriptionText, disposables, - relatedInfoService, relatedStreamUrl, timestampMatchDTO), - timestampMatchDTO.timestampStart(), - timestampMatchDTO.timestampEnd(), - 0); - } - } - - private static void setTextViewCharSequence(@NonNull final TextView textView, - @Nullable final CharSequence charSequence, - @Nullable final Consumer onCompletion) { - textView.setText(charSequence); - textView.setVisibility(View.VISIBLE); - if (onCompletion != null) { - onCompletion.accept(textView); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextViewExtensions.kt b/app/src/main/java/org/schabi/newpipe/util/text/TextViewExtensions.kt deleted file mode 100644 index d2efbf541..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextViewExtensions.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.schabi.newpipe.util.text - -import android.content.res.Resources -import android.text.SpannableString -import android.text.method.LinkMovementMethod -import android.text.util.Linkify -import android.util.Patterns -import android.widget.TextView -import androidx.annotation.StringRes -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import androidx.core.text.toSpanned - -/** - * Takes in a CharSequence [text] - * and makes raw HTTP URLs and HTML anchor tags clickable - */ -fun TextView.setTextWithLinks(text: CharSequence) { - val spanned = SpannableString(text) - // Using the pattern overload of addLinks since the one with the int masks strips all spans from the text before applying new ones - Linkify.addLinks(spanned, Patterns.WEB_URL, null) - this.text = spanned - this.movementMethod = LinkMovementMethod.getInstance() -} - -/** - * Gets text from string resource with [id] while preserving styling and allowing string format value substitution of [formatArgs] - */ -fun Resources.getText(@StringRes id: Int, vararg formatArgs: Any?): CharSequence = getText(id).toSpanned().toHtml().format(*formatArgs).parseAsHtml() diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java deleted file mode 100644 index b1357b943..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.schabi.newpipe.util.text; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Extracts timestamps. - */ -public final class TimestampExtractor { - public static final Pattern TIMESTAMPS_PATTERN = Pattern.compile( - "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)"); - - private TimestampExtractor() { - // No impl pls - } - - /** - * Gets a single timestamp from a matcher. - * - * @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN} - * @param baseText the text where the pattern was applied to / where the matcher is - * based upon - * @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise - * {@code null}. - */ - @Nullable - public static TimestampMatchDTO getTimestampFromMatcher( - @NonNull final Matcher timestampMatches, - @NonNull final String baseText) { - int timestampStart = timestampMatches.start(1); - if (timestampStart == -1) { - timestampStart = timestampMatches.start(2); - } - final int timestampEnd = timestampMatches.end(3); - - final String parsedTimestamp = baseText.substring(timestampStart, timestampEnd); - final String[] timestampParts = parsedTimestamp.split(":"); - - final int seconds; - if (timestampParts.length == 3) { // timestamp format: XX:XX:XX - seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours - + Integer.parseInt(timestampParts[1]) * 60 // minutes - + Integer.parseInt(timestampParts[2]); // seconds - } else if (timestampParts.length == 2) { // timestamp format: XX:XX - seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes - + Integer.parseInt(timestampParts[1]); // seconds - } else { - return null; - } - - return new TimestampMatchDTO(timestampStart, timestampEnd, seconds); - } - - public record TimestampMatchDTO(int timestampStart, int timestampEnd, int seconds) { - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt deleted file mode 100644 index c99e2f639..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.util.text - -import android.content.Context -import android.view.View -import io.reactivex.rxjava3.disposables.CompositeDisposable -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.StreamingService -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO - -class TimestampLongPressClickableSpan( - private val context: Context, - private val descriptionText: String, - private val disposables: CompositeDisposable, - private val relatedInfoService: StreamingService, - private val relatedStreamUrl: String, - private val timestampMatchDTO: TimestampMatchDTO -) : LongPressClickableSpan() { - override fun onClick(view: View) { - InternalUrlsHandler.playOnPopup( - context, - relatedStreamUrl, - relatedInfoService, - timestampMatchDTO.seconds() - ) - } - - override fun onLongClick(view: View) { - ShareUtils.copyToClipboard( - context, - getTimestampTextToCopy( - relatedInfoService, - relatedStreamUrl, - descriptionText, - timestampMatchDTO - ) - ) - } - - companion object { - private fun getTimestampTextToCopy( - relatedInfoService: StreamingService, - relatedStreamUrl: String, - descriptionText: String, - timestampMatchDTO: TimestampMatchDTO - ): String { - // TODO: use extractor methods to get timestamps when this feature will be implemented in it - when (relatedInfoService) { - ServiceList.YouTube -> - return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds() - - ServiceList.SoundCloud, ServiceList.MediaCCC -> - return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds() - - ServiceList.PeerTube -> - return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds() - } - - // Return timestamp text for other services - return descriptionText.substring( - timestampMatchDTO.timestampStart(), - timestampMatchDTO.timestampEnd() - ) - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java deleted file mode 100644 index 5c0db20a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.text.Layout; -import android.view.MotionEvent; -import android.widget.TextView; - -import androidx.annotation.NonNull; - -public final class TouchUtils { - - private TouchUtils() { - } - - /** - * Get the character offset on the closest line to the position pressed by the user of a - * {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}. - * - * @param textView the {@link TextView} on which the {@link MotionEvent} was fired - * @param event the {@link MotionEvent} which was fired - * @return the character offset on the closest line to the position pressed by the user - */ - public static int getOffsetForHorizontalLine(@NonNull final TextView textView, - @NonNull final MotionEvent event) { - - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= textView.getTotalPaddingLeft(); - y -= textView.getTotalPaddingTop(); - - x += textView.getScrollX(); - y += textView.getScrollY(); - - final Layout layout = textView.getLayout(); - final int line = layout.getLineForVertical(y); - return layout.getOffsetForHorizontal(line, x); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java deleted file mode 100644 index ec3cefc62..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.content.Context; -import android.view.View; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.util.external_communication.ShareUtils; - -final class UrlLongPressClickableSpan extends LongPressClickableSpan { - - @NonNull - private final Context context; - @NonNull - private final String url; - - UrlLongPressClickableSpan(@NonNull final Context context, - @NonNull final String url) { - this.context = context; - this.url = url; - } - - @Override - public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) { - ShareUtils.openUrlInApp(context, url); - } - } - - @Override - public void onLongClick(@NonNull final View view) { - ShareUtils.copyToClipboard(context, url); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java deleted file mode 100644 index 49be86ae0..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java +++ /dev/null @@ -1,117 +0,0 @@ -/* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */ - -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.schabi.newpipe.util.urlfinder; - -import java.util.regex.Pattern; - -/** - * Commonly used regular expression patterns. - */ -public final class PatternsCompat { - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // CHANGED: Removed unused code // - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - - public static final Pattern IP_ADDRESS = Pattern.compile( - "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" - + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" - + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" - + "|[1-9][0-9]|[0-9]))"); - - /** - * Valid UCS characters defined in RFC 3987. Excludes space characters. - */ - private static final String UCS_CHAR = "[" - + "\u00A0-\uD7FF" - + "\uF900-\uFDCF" - + "\uFDF0-\uFFEF" - + "\uD800\uDC00-\uD83F\uDFFD" - + "\uD840\uDC00-\uD87F\uDFFD" - + "\uD880\uDC00-\uD8BF\uDFFD" - + "\uD8C0\uDC00-\uD8FF\uDFFD" - + "\uD900\uDC00-\uD93F\uDFFD" - + "\uD940\uDC00-\uD97F\uDFFD" - + "\uD980\uDC00-\uD9BF\uDFFD" - + "\uD9C0\uDC00-\uD9FF\uDFFD" - + "\uDA00\uDC00-\uDA3F\uDFFD" - + "\uDA40\uDC00-\uDA7F\uDFFD" - + "\uDA80\uDC00-\uDABF\uDFFD" - + "\uDAC0\uDC00-\uDAFF\uDFFD" - + "\uDB00\uDC00-\uDB3F\uDFFD" - + "\uDB44\uDC00-\uDB7F\uDFFD" - + "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]"; - - /** - * Valid characters for IRI label defined in RFC 3987. - */ - private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR; - - /** - * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets. - */ - private static final String IRI_LABEL = - "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; - - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // CHANGED: Removed rtsp from supported protocols // - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - private static final String PROTOCOL = "(?i:http|https)://"; - - /* A word boundary or end of input. This is to stop foo.sure from matching as foo.su */ - private static final String WORD_BOUNDARY = "(?:\\b|$|^)"; - - private static final String USER_INFO = "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" - + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" - + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@"; - - private static final String PORT_NUMBER = "\\:\\d{1,5}"; - - private static final String PATH_AND_QUERY = "[/\\?](?:(?:[" + LABEL_CHAR - + ";/\\?:@&=#~" // plus optional query params - + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*"; - - /** - * Regular expression that matches domain names without a TLD. - */ - private static final String RELAXED_DOMAIN_NAME = - "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")"; - - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // CHANGED: Field visibility was modified // - //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - /** - * Regular expression to match strings that start with a supported protocol. Rules for domain - * names and TLDs are more relaxed. TLDs are optional. - */ - /*package*/ static final String WEB_URL_WITH_PROTOCOL = "(" - + WORD_BOUNDARY - + "(?:" - + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")" - + "(?:" + RELAXED_DOMAIN_NAME + ")?" - + "(?:" + PORT_NUMBER + ")?" - + ")" - + "(?:" + PATH_AND_QUERY + ")?" - + WORD_BOUNDARY - + ")"; - - /** - * Do not create this static utility class. - */ - private PatternsCompat() { } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/UrlFinder.kt b/app/src/main/java/org/schabi/newpipe/util/urlfinder/UrlFinder.kt deleted file mode 100644 index 503fa2094..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/urlfinder/UrlFinder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.schabi.newpipe.util.urlfinder - -import java.util.regex.Pattern - -class UrlFinder { - companion object { - private val WEB_URL_WITH_PROTOCOL = Pattern.compile(PatternsCompat.WEB_URL_WITH_PROTOCOL) - - /** - * @return the first url found in the input, null otherwise. - */ - @JvmStatic - fun firstUrlFromInput(input: String?): String? { - if (input.isNullOrEmpty()) { - return null - } - - val matcher = WEB_URL_WITH_PROTOCOL.matcher(input) - - if (matcher.find()) { - return matcher.group() - } - - return null - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java deleted file mode 100644 index b1fabe715..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.animation.AccelerateDecelerateInterpolator; -import android.view.animation.Animation; -import android.view.animation.Transformation; -import android.widget.ProgressBar; - -import androidx.annotation.Nullable; - -public final class AnimatedProgressBar extends ProgressBar { - @Nullable - private ProgressBarAnimation animation = null; - - public AnimatedProgressBar(final Context context) { - super(context); - } - - public AnimatedProgressBar(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - public AnimatedProgressBar(final Context context, final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public synchronized void setProgressAnimated(final int progress) { - cancelAnimation(); - animation = new ProgressBarAnimation(this, getProgress(), progress); - startAnimation(animation); - } - - private void cancelAnimation() { - if (animation != null) { - animation.cancel(); - animation = null; - } - clearAnimation(); - } - - private static class ProgressBarAnimation extends Animation { - - private final AnimatedProgressBar progressBar; - private final float from; - private final float to; - - ProgressBarAnimation(final AnimatedProgressBar progressBar, final float from, - final float to) { - super(); - this.progressBar = progressBar; - this.from = from; - this.to = to; - setDuration(500); - setInterpolator(new AccelerateDecelerateInterpolator()); - } - - @Override - protected void applyTransformation(final float interpolatedTime, final Transformation t) { - super.applyTransformation(interpolatedTime, t); - final float value = from + (to - from) * interpolatedTime; - progressBar.setProgress((int) value); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java deleted file mode 100644 index dc667b22a..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -import com.google.android.material.appbar.CollapsingToolbarLayout; - -public class CustomCollapsingToolbarLayout extends CollapsingToolbarLayout { - public CustomCollapsingToolbarLayout(@NonNull final Context context) { - super(context); - overrideListener(); - } - - public CustomCollapsingToolbarLayout(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - overrideListener(); - } - - public CustomCollapsingToolbarLayout(@NonNull final Context context, - @Nullable final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - overrideListener(); - } - - /** - * CollapsingToolbarLayout sets it's own setOnApplyInsetsListener which consumes - * system insets {@link CollapsingToolbarLayout#onWindowInsetChanged(WindowInsetsCompat)} - * so we will not receive them in subviews with fitsSystemWindows = true. - * Override Google's behavior - * */ - public void overrideListener() { - ViewCompat.setOnApplyWindowInsetsListener(this, (v, insets) -> insets); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java deleted file mode 100644 index 7452fff09..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java +++ /dev/null @@ -1,110 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.SurfaceView; - -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; - -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; - -public class ExpandableSurfaceView extends SurfaceView { - private int resizeMode = RESIZE_MODE_FIT; - private int baseHeight = 0; - private int maxHeight = 0; - private float videoAspectRatio = 0.0f; - private float scaleX = 1.0f; - private float scaleY = 1.0f; - - public ExpandableSurfaceView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (videoAspectRatio == 0.0f) { - return; - } - - int width = MeasureSpec.getSize(widthMeasureSpec); - final boolean verticalVideo = videoAspectRatio < 1; - // Use maxHeight only on non-fit resize mode and in vertical videos - int height = maxHeight != 0 - && resizeMode != RESIZE_MODE_FIT - && verticalVideo ? maxHeight : baseHeight; - - if (width == 0 || height == 0) { - return; - } - - final float viewAspectRatio = width / ((float) height); - final float aspectDeformation = (videoAspectRatio / viewAspectRatio) - 1; - scaleX = 1.0f; - scaleY = 1.0f; - - if (resizeMode == RESIZE_MODE_FIT) { - if (aspectDeformation > 0) { - height = (int) (width / videoAspectRatio); - } else { - width = (int) (height * videoAspectRatio); - } - } else if (resizeMode == RESIZE_MODE_ZOOM) { - if (aspectDeformation < 0) { - scaleY = viewAspectRatio / videoAspectRatio; - } else { - scaleX = videoAspectRatio / viewAspectRatio; - } - } - - super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); - } - - /** - * Scale view only in {@link #onLayout} to make transition for ZOOM mode as smooth as possible. - */ - @Override - protected void onLayout(final boolean changed, - final int left, final int top, final int right, final int bottom) { - setScaleX(scaleX); - setScaleY(scaleY); - } - - /** - * @param base The height that will be used in every resize mode as a minimum height - * @param max The max height for vertical videos in non-FIT resize modes - */ - public void setHeights(final int base, final int max) { - if (baseHeight == base && maxHeight == max) { - return; - } - baseHeight = base; - maxHeight = max; - requestLayout(); - } - - public void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int newResizeMode) { - if (resizeMode == newResizeMode) { - return; - } - - resizeMode = newResizeMode; - requestLayout(); - } - - @AspectRatioFrameLayout.ResizeMode - public int getResizeMode() { - return resizeMode; - } - - public void setAspectRatio(final float aspectRatio) { - if (videoAspectRatio == aspectRatio || aspectRatio == 0 || !Float.isFinite(aspectRatio)) { - return; - } - - videoAspectRatio = aspectRatio; - requestLayout(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java deleted file mode 100644 index d4fafc31a..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * FocusAwareCoordinator.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.view.WindowInsetsCompat; - -import org.schabi.newpipe.R; - -public final class FocusAwareCoordinator extends CoordinatorLayout { - private final Rect childFocus = new Rect(); - - public FocusAwareCoordinator(@NonNull final Context context) { - super(context); - } - - public FocusAwareCoordinator(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - public FocusAwareCoordinator(@NonNull final Context context, - @Nullable final AttributeSet attrs, final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void requestChildFocus(final View child, final View focused) { - super.requestChildFocus(child, focused); - - if (!isInTouchMode()) { - if (focused.getHeight() >= getHeight()) { - focused.getFocusedRect(childFocus); - - ((ViewGroup) child).offsetDescendantRectToMyCoords(focused, childFocus); - } else { - focused.getHitRect(childFocus); - - ((ViewGroup) child).offsetDescendantRectToMyCoords((View) focused.getParent(), - childFocus); - } - - requestChildRectangleOnScreen(child, childFocus, false); - } - } - - /** - * Applies window insets to all children, not just for the first who consume the insets. - * Makes possible for multiple fragments to co-exist. Without this code - * the first ViewGroup who consumes will be the last who receive the insets - */ - @Override - public WindowInsets dispatchApplyWindowInsets(final WindowInsets insets) { - boolean consumed = false; - for (int i = 0; i < getChildCount(); i++) { - final View child = getChildAt(i); - final WindowInsets res = child.dispatchApplyWindowInsets(insets); - if (res.isConsumed()) { - consumed = true; - } - } - - return consumed ? WindowInsetsCompat.CONSUMED.toWindowInsets() : insets; - } - - /** - * Adjusts player's controls manually because onApplyWindowInsets doesn't work when multiple - * receivers adjust its bounds. So when two listeners are present (like in profile page) - * the player's controls will not receive insets. This method fixes it - */ - @Override - public WindowInsets onApplyWindowInsets(final WindowInsets windowInsets) { - final var windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, this); - final var insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); - final ViewGroup controls = findViewById(R.id.playbackControlRoot); - if (controls != null) { - controls.setPadding(insets.left, insets.top, insets.right, insets.bottom); - } - return super.onApplyWindowInsets(windowInsets); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java deleted file mode 100644 index 5c694c3a9..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * FocusAwareDrawerLayout.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.drawerlayout.widget.DrawerLayout; - -import java.util.ArrayList; - -public final class FocusAwareDrawerLayout extends DrawerLayout { - public FocusAwareDrawerLayout(@NonNull final Context context) { - super(context); - } - - public FocusAwareDrawerLayout(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - public FocusAwareDrawerLayout(@NonNull final Context context, - @Nullable final AttributeSet attrs, - final int defStyle) { - super(context, attrs, defStyle); - } - - @Override - protected boolean onRequestFocusInDescendants(final int direction, - final Rect previouslyFocusedRect) { - // SDK implementation of this method picks whatever visible View takes the focus first - // without regard to addFocusables. If the open drawer is temporarily empty, the focus - // escapes outside of it, which can be confusing - - boolean hasOpenPanels = false; - - for (int i = 0; i < getChildCount(); ++i) { - final View child = getChildAt(i); - - final DrawerLayout.LayoutParams lp = - (DrawerLayout.LayoutParams) child.getLayoutParams(); - - if (lp.gravity != 0 && isDrawerVisible(child)) { - hasOpenPanels = true; - - if (child.requestFocus(direction, previouslyFocusedRect)) { - return true; - } - } - } - - if (hasOpenPanels) { - return false; - } - - return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); - } - - @Override - public void addFocusables(final ArrayList views, final int direction, - final int focusableMode) { - boolean hasOpenPanels = false; - View content = null; - - for (int i = 0; i < getChildCount(); ++i) { - final View child = getChildAt(i); - - final DrawerLayout.LayoutParams lp = - (DrawerLayout.LayoutParams) child.getLayoutParams(); - - if (lp.gravity == 0) { - content = child; - } else { - if (isDrawerVisible(child)) { - hasOpenPanels = true; - child.addFocusables(views, direction, focusableMode); - } - } - } - - if (content != null && !hasOpenPanels) { - content.addFocusables(views, direction, focusableMode); - } - } - - // this override isn't strictly necessary, but it is helpful when DrawerLayout isn't - // the topmost view in hierarchy (such as when system or builtin appcompat ActionBar is used) - @Override - @SuppressLint("RtlHardcoded") - public void openDrawer(@NonNull final View drawerView, final boolean animate) { - super.openDrawer(drawerView, animate); - - drawerView.requestFocus(FOCUS_FORWARD); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java deleted file mode 100644 index 8176a9aef..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * FocusAwareDrawerLayout.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.ViewTreeObserver; -import android.widget.SeekBar; - -import androidx.appcompat.widget.AppCompatSeekBar; - -import org.schabi.newpipe.util.DeviceUtils; - -/** - * SeekBar, adapted for directional navigation. It emulates touch-related callbacks - * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to - * work with it. - */ -public final class FocusAwareSeekBar extends AppCompatSeekBar { - private NestedListener listener; - - private ViewTreeObserver treeObserver; - - public FocusAwareSeekBar(final Context context) { - super(context); - } - - public FocusAwareSeekBar(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - public FocusAwareSeekBar(final Context context, final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setOnSeekBarChangeListener(final OnSeekBarChangeListener l) { - this.listener = l == null ? null : new NestedListener(l); - - super.setOnSeekBarChangeListener(listener); - } - - @Override - public boolean onKeyDown(final int keyCode, final KeyEvent event) { - if (!isInTouchMode() && DeviceUtils.isConfirmKey(keyCode)) { - releaseTrack(); - } - - return super.onKeyDown(keyCode, event); - } - - @Override - protected void onFocusChanged(final boolean gainFocus, final int direction, - final Rect previouslyFocusedRect) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); - - if (!isInTouchMode() && !gainFocus) { - releaseTrack(); - } - } - - private final ViewTreeObserver.OnTouchModeChangeListener touchModeListener = isInTouchMode -> { - if (isInTouchMode) { - releaseTrack(); - } - }; - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - - treeObserver = getViewTreeObserver(); - treeObserver.addOnTouchModeChangeListener(touchModeListener); - } - - @Override - protected void onDetachedFromWindow() { - if (treeObserver == null || !treeObserver.isAlive()) { - treeObserver = getViewTreeObserver(); - } - - treeObserver.removeOnTouchModeChangeListener(touchModeListener); - treeObserver = null; - - super.onDetachedFromWindow(); - } - - private void releaseTrack() { - if (listener != null && listener.isSeeking) { - listener.onStopTrackingTouch(this); - } - } - - private static final class NestedListener implements OnSeekBarChangeListener { - private final OnSeekBarChangeListener delegate; - - boolean isSeeking; - - private NestedListener(final OnSeekBarChangeListener delegate) { - this.delegate = delegate; - } - - @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - if (!seekBar.isInTouchMode() && !isSeeking && fromUser) { - isSeeking = true; - - onStartTrackingTouch(seekBar); - } - - delegate.onProgressChanged(seekBar, progress, fromUser); - } - - @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - isSeeking = true; - - delegate.onStartTrackingTouch(seekBar); - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - isSeeking = false; - - delegate.onStopTrackingTouch(seekBar); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java deleted file mode 100644 index 9e06211f2..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright 2019 Alexander Rvachev - * FocusOverlayView.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.schabi.newpipe.views; - -import android.app.Activity; -import android.app.Dialog; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.Window; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; - -import org.schabi.newpipe.R; - -import java.lang.ref.WeakReference; - -public final class FocusOverlayView extends Drawable implements - ViewTreeObserver.OnGlobalFocusChangeListener, - ViewTreeObserver.OnDrawListener, - ViewTreeObserver.OnGlobalLayoutListener, - ViewTreeObserver.OnScrollChangedListener, ViewTreeObserver.OnTouchModeChangeListener { - - private boolean isInTouchMode; - - private final Rect focusRect = new Rect(); - - private final Paint rectPaint = new Paint(); - - private final Handler animator = new Handler(Looper.getMainLooper()) { - @Override - public void handleMessage(final Message msg) { - updateRect(); - } - }; - - private WeakReference focused; - - public FocusOverlayView(final Context context) { - rectPaint.setStyle(Paint.Style.STROKE); - rectPaint.setStrokeWidth(2); - rectPaint.setColor(context.getResources().getColor(R.color.white)); - } - - @Override - public void onGlobalFocusChanged(final View oldFocus, final View newFocus) { - if (newFocus != null) { - focused = new WeakReference<>(newFocus); - } else { - focused = null; - } - - updateRect(); - - animator.sendEmptyMessageDelayed(0, 1000); - } - - private void updateRect() { - final View focusedView = focused == null ? null : this.focused.get(); - - final int l = focusRect.left; - final int r = focusRect.right; - final int t = focusRect.top; - final int b = focusRect.bottom; - - if (focusedView != null && isShown(focusedView)) { - focusedView.getGlobalVisibleRect(focusRect); - } - - if (shouldClearFocusRect(focusedView, focusRect)) { - focusRect.setEmpty(); - } - - if (l != focusRect.left || r != focusRect.right - || t != focusRect.top || b != focusRect.bottom) { - invalidateSelf(); - } - } - - private boolean isShown(@NonNull final View view) { - return view.getWidth() != 0 && view.getHeight() != 0 && view.isShown(); - } - - @Override - public void onDraw() { - updateRect(); - } - - @Override - public void onScrollChanged() { - updateRect(); - - animator.removeMessages(0); - animator.sendEmptyMessageDelayed(0, 1000); - } - - @Override - public void onGlobalLayout() { - updateRect(); - - animator.sendEmptyMessageDelayed(0, 1000); - } - - @Override - public void onTouchModeChanged(final boolean inTouchMode) { - this.isInTouchMode = inTouchMode; - - if (inTouchMode) { - updateRect(); - } else { - invalidateSelf(); - } - } - - public void setCurrentFocus(final View newFocus) { - if (newFocus == null) { - return; - } - - this.isInTouchMode = newFocus.isInTouchMode(); - - onGlobalFocusChanged(null, newFocus); - } - - @Override - public void draw(@NonNull final Canvas canvas) { - if (!isInTouchMode && focusRect.width() != 0) { - canvas.drawRect(focusRect, rectPaint); - } - } - - @Override - public int getOpacity() { - return PixelFormat.TRANSPARENT; - } - - @Override - public void setAlpha(final int alpha) { - } - - @Override - public void setColorFilter(final ColorFilter colorFilter) { - } - - /* - * When any view in the player looses it's focus (after setVisibility(GONE)) the focus gets - * added to the whole fragment which has a width and height equal to the window frame. - * The easiest way to avoid the unneeded frame is to skip highlighting of rect that is - * equal to the overlayView bounds - * */ - private boolean shouldClearFocusRect(@Nullable final View focusedView, final Rect focusedRect) { - return focusedView == null || focusedRect.equals(getBounds()); - } - - public static void setupFocusObserver(final Dialog dialog) { - final Rect displayRect = new Rect(); - - final Window window = dialog.getWindow(); - assert window != null; - - final View decor = window.getDecorView(); - decor.getWindowVisibleDisplayFrame(displayRect); - - final FocusOverlayView overlay = new FocusOverlayView(dialog.getContext()); - overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); - - setupOverlay(window, overlay); - } - - public static void setupFocusObserver(final Activity activity) { - final Rect displayRect = new Rect(); - - final Window window = activity.getWindow(); - final View decor = window.getDecorView(); - decor.getWindowVisibleDisplayFrame(displayRect); - - final FocusOverlayView overlay = new FocusOverlayView(activity); - overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); - - setupOverlay(window, overlay); - } - - private static void setupOverlay(final Window window, final FocusOverlayView overlay) { - final ViewGroup decor = (ViewGroup) window.getDecorView(); - decor.getOverlay().add(overlay); - - fixFocusHierarchy(decor); - - final ViewTreeObserver observer = decor.getViewTreeObserver(); - observer.addOnScrollChangedListener(overlay); - observer.addOnGlobalFocusChangeListener(overlay); - observer.addOnGlobalLayoutListener(overlay); - observer.addOnTouchModeChangeListener(overlay); - observer.addOnDrawListener(overlay); - - overlay.setCurrentFocus(decor.getFocusedChild()); - - // Some key presses don't actually move focus, but still result in movement on screen. - // For example, MovementMethod of TextView may cause requestRectangleOnScreen() due to - // some "focusable" spans, which in turn causes CoordinatorLayout to "scroll" it's children. - // Unfortunately many such forms of "scrolling" do not count as scrolling for purpose - // of dispatching ViewTreeObserver callbacks, so we have to intercept them by directly - // receiving keys from Window. - window.setCallback(new SimpleWindowCallback(window.getCallback()) { - @Override - public boolean dispatchKeyEvent(final KeyEvent event) { - final boolean res = super.dispatchKeyEvent(event); - overlay.onKey(event); - return res; - } - }); - } - - private void onKey(final KeyEvent event) { - if (event.getAction() != KeyEvent.ACTION_DOWN) { - return; - } - - updateRect(); - - animator.sendEmptyMessageDelayed(0, 100); - } - - private static void fixFocusHierarchy(final View decor) { - // During Android 8 development some dumb ass decided, that action bar has to be - // a keyboard focus cluster. Unfortunately, keyboard clusters do not work for primary - // auditory of key navigation — Android TV users (Android TV remotes do not have - // keyboard META key for moving between clusters). We have to fix this unfortunate accident - // While we are at it, let's deal with touchscreenBlocksFocus too. - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - - if (!(decor instanceof ViewGroup)) { - return; - } - - clearFocusObstacles((ViewGroup) decor); - } - - @RequiresApi(api = Build.VERSION_CODES.O) - private static void clearFocusObstacles(final ViewGroup viewGroup) { - viewGroup.setTouchscreenBlocksFocus(false); - - if (viewGroup.isKeyboardNavigationCluster()) { - viewGroup.setKeyboardNavigationCluster(false); - - return; // clusters aren't supposed to nest - } - - final int childCount = viewGroup.getChildCount(); - - for (int i = 0; i < childCount; ++i) { - final View view = viewGroup.getChildAt(i); - - if (view instanceof ViewGroup) { - clearFocusObstacles((ViewGroup) view); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java deleted file mode 100644 index f0993055e..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatEditText; - -import org.schabi.newpipe.util.NewPipeTextViewHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -/** - * An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)} - * when sharing selected text by using the {@code Share} command of the floating actions. - * - *

- * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing - * text from {@link AppCompatEditText} on EMUI devices. - *

- */ -public class NewPipeEditText extends AppCompatEditText { - - public NewPipeEditText(@NonNull final Context context) { - super(context); - } - - public NewPipeEditText(@NonNull final Context context, @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - public NewPipeEditText(@NonNull final Context context, - @Nullable final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public boolean onTextContextMenuItem(final int id) { - if (id == android.R.id.shareText) { - NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this); - return true; - } - return super.onTextContextMenuItem(id); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java deleted file mode 100644 index 23b961297..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * NewPipeRecyclerView.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.graphics.Rect; -import android.os.Build; -import android.util.AttributeSet; -import android.util.Log; -import android.view.FocusFinder; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -public class NewPipeRecyclerView extends RecyclerView { - private static final String TAG = "NewPipeRecyclerView"; - - private final Rect focusRect = new Rect(); - private final Rect tempFocus = new Rect(); - - private boolean allowDpadScroll = true; - - public NewPipeRecyclerView(@NonNull final Context context) { - super(context); - - init(); - } - - public NewPipeRecyclerView(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - - init(); - } - - public NewPipeRecyclerView(@NonNull final Context context, - @Nullable final AttributeSet attrs, final int defStyle) { - super(context, attrs, defStyle); - - init(); - } - - private void init() { - setFocusable(true); - - setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); - } - - public void setFocusScrollAllowed(final boolean allowed) { - this.allowDpadScroll = allowed; - } - - @Override - public View focusSearch(final View focused, final int direction) { - // RecyclerView has buggy focusSearch(), that calls into Adapter several times, - // but ultimately fails to produce correct results in many cases. To add insult to injury, - // it's focusSearch() hard-codes several behaviors, incompatible with widely accepted focus - // handling practices: RecyclerView does not allow Adapter to give focus to itself (!!) and - // always checks, that returned View is located in "correct" direction (which prevents us - // from temporarily giving focus to special hidden View). - return null; - } - - @Override - protected void removeDetachedView(final View child, final boolean animate) { - if (child.hasFocus()) { - // If the focused child is being removed (can happen during very fast scrolling), - // temporarily give focus to ourselves. This will usually result in another child - // gaining focus (which one does not really matter, because at that point scrolling - // is FAST, and that child will soon be off-screen too) - requestFocus(); - } - - super.removeDetachedView(child, animate); - } - - // we override focusSearch to always return null, so all moves moves lead to - // dispatchUnhandledMove(). As added advantage, we can fully swallow some kinds of moves - // (such as downward movement, that happens when loading additional contents is in progress - - @Override - public boolean dispatchUnhandledMove(final View focused, final int direction) { - tempFocus.setEmpty(); - - // save focus rect before further manipulation (both focusSearch() and scrollBy() - // can mess with focused View by moving it off-screen and detaching) - - if (focused != null) { - final View focusedItem = findContainingItemView(focused); - if (focusedItem != null) { - focusedItem.getHitRect(focusRect); - } - } - - // call focusSearch() to initiate layout, but disregard returned View for now - final View adapterResult = super.focusSearch(focused, direction); - if (adapterResult != null && !isOutside(adapterResult)) { - adapterResult.requestFocus(direction); - return true; - } - - if (arrowScroll(direction)) { - // if RecyclerView can not yield focus, but there is still some scrolling space in - // indicated, direction, scroll some fixed amount in that direction - // (the same logic in ScrollView) - return true; - } - - if (focused != this && direction == FOCUS_DOWN && !allowDpadScroll) { - Log.i(TAG, "Consuming downward scroll: content load in progress"); - return true; - } - - if (tryFocusFinder(direction)) { - return true; - } - - if (adapterResult != null) { - adapterResult.requestFocus(direction); - return true; - } - - return super.dispatchUnhandledMove(focused, direction); - } - - private boolean tryFocusFinder(final int direction) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // Android 9 implemented bunch of handy changes to focus, that render code below less - // useful, and also broke findNextFocusFromRect in way, that render this hack useless - return false; - } - - final FocusFinder finder = FocusFinder.getInstance(); - - // try to use FocusFinder instead of adapter - final ViewGroup root = (ViewGroup) getRootView(); - - tempFocus.set(focusRect); - - root.offsetDescendantRectToMyCoords(this, tempFocus); - - final View focusFinderResult = finder.findNextFocusFromRect(root, tempFocus, direction); - if (focusFinderResult != null && !isOutside(focusFinderResult)) { - focusFinderResult.requestFocus(direction); - return true; - } - - // look for focus in our ancestors, increasing search scope with each failure - // this provides much better locality than using FocusFinder with root - ViewGroup parent = (ViewGroup) getParent(); - - while (parent != root) { - tempFocus.set(focusRect); - - parent.offsetDescendantRectToMyCoords(this, tempFocus); - - final View candidate = finder.findNextFocusFromRect(parent, tempFocus, direction); - if (candidate != null && candidate.requestFocus(direction)) { - return true; - } - - parent = (ViewGroup) parent.getParent(); - } - - return false; - } - - private boolean arrowScroll(final int direction) { - switch (direction) { - case FOCUS_DOWN: - if (!canScrollVertically(1)) { - return false; - } - scrollBy(0, 100); - break; - case FOCUS_UP: - if (!canScrollVertically(-1)) { - return false; - } - scrollBy(0, -100); - break; - case FOCUS_LEFT: - if (!canScrollHorizontally(-1)) { - return false; - } - scrollBy(-100, 0); - break; - case FOCUS_RIGHT: - if (!canScrollHorizontally(-1)) { - return false; - } - scrollBy(100, 0); - break; - default: - return false; - } - - return true; - } - - private boolean isOutside(final View view) { - return findContainingItemView(view) == null; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java deleted file mode 100644 index dd3f20f40..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.text.method.MovementMethod; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatTextView; - -import org.schabi.newpipe.util.NewPipeTextViewHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -/** - * An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)} - * when sharing selected text by using the {@code Share} command of the floating actions. - * - *

- * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing - * text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a - * text change occurs, if the text cannot be selected and text links are clickable. - *

- */ -public class NewPipeTextView extends AppCompatTextView { - - public NewPipeTextView(@NonNull final Context context) { - super(context); - } - - public NewPipeTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - public NewPipeTextView(@NonNull final Context context, - @Nullable final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setText(final CharSequence text, final BufferType type) { - // We need to set again the movement method after a text change because Android resets the - // movement method to the default one in the case where the text cannot be selected and - // text links are clickable (which is the default case in NewPipe). - final MovementMethod movementMethod = this.getMovementMethod(); - super.setText(text, type); - setMovementMethod(movementMethod); - } - - @Override - public boolean onTextContextMenuItem(final int id) { - if (id == android.R.id.shareText) { - NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this); - return true; - } - return super.onTextContextMenuItem(id); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java b/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java deleted file mode 100644 index fb21a8083..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java +++ /dev/null @@ -1,134 +0,0 @@ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.NonNull; - -import com.google.android.material.tabs.TabLayout; - -/** - * A TabLayout that is scrollable when tabs exceed its width. - * Hides when there are less than 2 tabs. - */ -public class ScrollableTabLayout extends TabLayout { - private static final String TAG = ScrollableTabLayout.class.getSimpleName(); - - private int layoutWidth = 0; - private int prevVisibility = View.GONE; - - public ScrollableTabLayout(final Context context) { - super(context); - } - - public ScrollableTabLayout(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - public ScrollableTabLayout(final Context context, final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - protected void onLayout(final boolean changed, final int l, final int t, final int r, - final int b) { - super.onLayout(changed, l, t, r, b); - - remeasureTabs(); - } - - @Override - protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - - layoutWidth = w; - } - - @Override - public void addTab(@NonNull final Tab tab, final int position, final boolean setSelected) { - super.addTab(tab, position, setSelected); - - hasMultipleTabs(); - - // Adding a tab won't decrease total tabs' width so tabMode won't have to change to FIXED - if (getTabMode() != MODE_SCROLLABLE) { - remeasureTabs(); - } - } - - @Override - public void removeTabAt(final int position) { - super.removeTabAt(position); - - hasMultipleTabs(); - - // Removing a tab won't increase total tabs' width - // so tabMode won't have to change to SCROLLABLE - if (getTabMode() != MODE_FIXED) { - remeasureTabs(); - } - } - - @Override - protected void onVisibilityChanged(final View changedView, final int visibility) { - super.onVisibilityChanged(changedView, visibility); - - // Check width if some tabs have been added/removed while ScrollableTabLayout was invisible - // We don't have to check if it was GONE because then requestLayout() will be called - if (changedView == this) { - if (prevVisibility == View.INVISIBLE) { - remeasureTabs(); - } - prevVisibility = visibility; - } - } - - private void setMode(final int mode) { - if (mode == getTabMode()) { - return; - } - - setTabMode(mode); - } - - /** - * Make ScrollableTabLayout not visible if there are less than two tabs. - */ - private void hasMultipleTabs() { - if (getTabCount() > 1) { - setVisibility(View.VISIBLE); - } else { - setVisibility(View.GONE); - } - } - - /** - * Calculate minimal width required by tabs and set tabMode accordingly. - */ - private void remeasureTabs() { - if (prevVisibility != View.VISIBLE) { - return; - } - if (layoutWidth == 0) { - return; - } - - final int count = getTabCount(); - int contentWidth = 0; - for (int i = 0; i < count; i++) { - final View child = getTabAt(i).view; - if (child.getVisibility() == View.VISIBLE) { - // Use tab's minimum requested width should actual content be too small - contentWidth += Math.max(child.getMinimumWidth(), child.getMeasuredWidth()); - } - } - - if (contentWidth > layoutWidth) { - setMode(TabLayout.MODE_SCROLLABLE); - } else { - setMode(TabLayout.MODE_FIXED); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/SimpleWindowCallback.kt b/app/src/main/java/org/schabi/newpipe/views/SimpleWindowCallback.kt deleted file mode 100644 index 46f58d24c..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/SimpleWindowCallback.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.views - -import android.os.Build -import android.view.KeyEvent -import android.view.KeyboardShortcutGroup -import android.view.Menu -import android.view.Window -import androidx.annotation.RequiresApi - -/** - * Simple window callback class to allow intercepting key events - * @see FocusOverlayView.setupOverlay - */ -open class SimpleWindowCallback(private val baseCallback: Window.Callback) : - Window.Callback by baseCallback { - - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - return baseCallback.dispatchKeyEvent(event) - } - - @RequiresApi(Build.VERSION_CODES.O) - override fun onPointerCaptureChanged(hasCapture: Boolean) { - baseCallback.onPointerCaptureChanged(hasCapture) - } - - @RequiresApi(Build.VERSION_CODES.N) - override fun onProvideKeyboardShortcuts( - data: List?, - menu: Menu?, - deviceId: Int - ) { - baseCallback.onProvideKeyboardShortcuts(data, menu, deviceId) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java deleted file mode 100644 index 62465d2a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * SuperScrollLayoutManager.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.graphics.Rect; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; - -public final class SuperScrollLayoutManager extends LinearLayoutManager { - private final Rect handy = new Rect(); - - private final ArrayList focusables = new ArrayList<>(); - - public SuperScrollLayoutManager(final Context context) { - super(context); - } - - @Override - public boolean requestChildRectangleOnScreen(@NonNull final RecyclerView parent, - @NonNull final View child, - @NonNull final Rect rect, - final boolean immediate, - final boolean focusedChildVisible) { - if (!parent.isInTouchMode()) { - // only activate when in directional navigation mode (Android TV etc) — fine grained - // touch scrolling is better served by nested scroll system - - if (!focusedChildVisible || getFocusedChild() == child) { - handy.set(rect); - - parent.offsetDescendantRectToMyCoords(child, handy); - - parent.requestRectangleOnScreen(handy, immediate); - } - } - - return super.requestChildRectangleOnScreen(parent, child, rect, immediate, - focusedChildVisible); - } - - @Nullable - @Override - public View onInterceptFocusSearch(@NonNull final View focused, final int direction) { - final View focusedItem = findContainingItemView(focused); - if (focusedItem == null) { - return super.onInterceptFocusSearch(focused, direction); - } - - final int listDirection = getAbsoluteDirection(direction); - if (listDirection == 0) { - return super.onInterceptFocusSearch(focused, direction); - } - - // FocusFinder has an oddity: it considers size of Views more important - // than closeness to source View. This means, that big Views far away from current item - // are preferred to smaller sub-View of closer item. Setting focusability of closer item - // to FOCUS_AFTER_DESCENDANTS does not solve this, because ViewGroup#addFocusables omits - // such parent itself from list, if any of children are focusable. - // Fortunately we can intercept focus search and implement our own logic, based purely - // on position along the LinearLayoutManager axis - - final ViewGroup recycler = (ViewGroup) focusedItem.getParent(); - - final int sourcePosition = getPosition(focusedItem); - if (sourcePosition == 0 && listDirection < 0) { - return super.onInterceptFocusSearch(focused, direction); - } - - View preferred = null; - - int distance = Integer.MAX_VALUE; - - focusables.clear(); - - recycler.addFocusables(focusables, direction, recycler.isInTouchMode() - ? View.FOCUSABLES_TOUCH_MODE - : View.FOCUSABLES_ALL); - - try { - for (final View view : focusables) { - if (view == focused || view == recycler) { - continue; - } - - if (view == focusedItem) { - // do not pass focus back to the item View itself - it makes no sense - // (we can still pass focus to it's children however) - continue; - } - - final int candidate = getDistance(sourcePosition, view, listDirection); - if (candidate < 0) { - continue; - } - - if (candidate < distance) { - distance = candidate; - preferred = view; - } - } - } finally { - focusables.clear(); - } - - return preferred; - } - - private int getAbsoluteDirection(final int direction) { - switch (direction) { - default: - break; - case View.FOCUS_FORWARD: - return 1; - case View.FOCUS_BACKWARD: - return -1; - } - - if (getOrientation() == RecyclerView.HORIZONTAL) { - switch (direction) { - default: - break; - case View.FOCUS_LEFT: - return getReverseLayout() ? 1 : -1; - case View.FOCUS_RIGHT: - return getReverseLayout() ? -1 : 1; - } - } else { - switch (direction) { - default: - break; - case View.FOCUS_UP: - return getReverseLayout() ? 1 : -1; - case View.FOCUS_DOWN: - return getReverseLayout() ? -1 : 1; - } - } - - return 0; - } - - private int getDistance(final int sourcePosition, final View candidate, final int direction) { - final View itemView = findContainingItemView(candidate); - if (itemView == null) { - return -1; - } - - final int position = getPosition(itemView); - - return direction * (position - sourcePosition); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt deleted file mode 100644 index 8554e7194..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.schabi.newpipe.views.player - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import android.util.AttributeSet -import android.view.View - -class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) { - - private var backgroundPaint = Paint() - - private var widthPx = 0 - private var heightPx = 0 - - // Background - - private var shapePath = Path() - private var arcSize: Float = 80f - private var isLeft = true - - init { - requireNotNull(context) { "Context is null." } - - backgroundPaint.apply { - style = Paint.Style.FILL - isAntiAlias = true - color = 0x30000000 - } - - val dm = context.resources.displayMetrics - widthPx = dm.widthPixels - heightPx = dm.heightPixels - - updatePathShape() - } - - fun updateArcSize(baseView: View) { - val newArcSize = baseView.height / 11.4f - if (arcSize != newArcSize) { - arcSize = newArcSize - updatePathShape() - } - } - - fun updatePosition(newIsLeft: Boolean) { - if (isLeft != newIsLeft) { - isLeft = newIsLeft - updatePathShape() - } - } - - private fun updatePathShape() { - val halfWidth = widthPx * 0.5f - - shapePath.reset() - - val w = if (isLeft) 0f else widthPx.toFloat() - val f = if (isLeft) 1 else -1 - - shapePath.moveTo(w, 0f) - shapePath.lineTo(f * (halfWidth - arcSize) + w, 0f) - shapePath.quadTo( - f * (halfWidth + arcSize) + w, - heightPx.toFloat() / 2, - f * (halfWidth - arcSize) + w, - heightPx.toFloat() - ) - shapePath.lineTo(w, heightPx.toFloat()) - - shapePath.close() - invalidate() - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - widthPx = w - heightPx = h - updatePathShape() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - canvas.clipPath(shapePath) - canvas.drawPath(shapePath, backgroundPaint) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt b/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt deleted file mode 100644 index 08b7df6af..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt +++ /dev/null @@ -1,149 +0,0 @@ -package org.schabi.newpipe.views.player - -import android.content.Context -import android.util.AttributeSet -import android.util.Log -import android.view.LayoutInflater -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.END -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START -import androidx.constraintlayout.widget.ConstraintSet -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.R -import org.schabi.newpipe.player.gesture.DisplayPortion -import org.schabi.newpipe.player.gesture.DoubleTapListener - -class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) : - ConstraintLayout(context, attrs), DoubleTapListener { - - private var secondsView: SecondsView - private var circleClipTapView: CircleClipTapView - private var rootConstraintLayout: ConstraintLayout - - private var wasForwarding: Boolean = false - - init { - LayoutInflater.from(context).inflate(R.layout.player_fast_seek_overlay, this, true) - - secondsView = findViewById(R.id.seconds_view) - circleClipTapView = findViewById(R.id.circle_clip_tap_view) - rootConstraintLayout = findViewById(R.id.root_constraint_layout) - - addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - circleClipTapView.updateArcSize(view) - } - } - - private var performListener: PerformListener? = null - - fun performListener(listener: PerformListener?) = apply { - performListener = listener - } - - private var seekSecondsSupplier: () -> Int = { 0 } - - fun seekSecondsSupplier(supplier: (() -> Int)?) = apply { - seekSecondsSupplier = supplier ?: { 0 } - } - - // Indicates whether this (double) tap is the first of a series - // Decides whether to call performListener.onAnimationStart or not - private var initTap: Boolean = false - - override fun onDoubleTapStarted(portion: DisplayPortion) { - if (DEBUG) { - Log.d(TAG, "onDoubleTapStarted called with portion = [$portion]") - } - - initTap = false - - secondsView.stopAnimation() - } - - override fun onDoubleTapProgressDown(portion: DisplayPortion) { - val shouldForward: Boolean = - performListener?.getFastSeekDirection(portion)?.directionAsBoolean ?: return - - if (DEBUG) { - Log.d( - TAG, - "onDoubleTapProgressDown called with " + - "shouldForward = [$shouldForward], " + - "wasForwarding = [$wasForwarding], " + - "initTap = [$initTap], " - ) - } - - /* - * Check if a initial tap occurred or if direction was switched - */ - if (!initTap || wasForwarding != shouldForward) { - // Reset seconds and update position - secondsView.seconds = 0 - changeConstraints(shouldForward) - circleClipTapView.updatePosition(!shouldForward) - secondsView.setForwarding(shouldForward) - - wasForwarding = shouldForward - - if (!initTap) { - initTap = true - } - } - - performListener?.onDoubleTap() - - secondsView.seconds += seekSecondsSupplier.invoke() - performListener?.seek(forward = shouldForward) - } - - override fun onDoubleTapFinished() { - if (DEBUG) { - Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]") - } - - if (initTap) performListener?.onDoubleTapEnd() - initTap = false - - secondsView.stopAnimation() - } - - private fun changeConstraints(forward: Boolean) { - val constraintSet = ConstraintSet() - with(constraintSet) { - clone(rootConstraintLayout) - clear(secondsView.id, if (forward) START else END) - connect( - secondsView.id, - if (forward) END else START, - PARENT_ID, - if (forward) END else START - ) - secondsView.startAnimation() - applyTo(rootConstraintLayout) - } - } - - interface PerformListener { - fun onDoubleTap() - fun onDoubleTapEnd() - - /** - * Determines if the playback should forward/rewind or do nothing. - */ - fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection - fun seek(forward: Boolean) - - enum class FastSeekDirection(val directionAsBoolean: Boolean?) { - NONE(null), - FORWARD(true), - BACKWARD(false) - } - } - - companion object { - private const val TAG = "PlayerFastSeekOverlay" - private val DEBUG = MainActivity.DEBUG - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt deleted file mode 100644 index 5e4885129..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.schabi.newpipe.views.player - -import android.animation.ValueAnimator -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import androidx.core.animation.addListener -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding -import org.schabi.newpipe.util.DeviceUtils - -class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { - - companion object { - const val ICON_ANIMATION_DURATION = 750L - } - - var cycleDuration: Long = ICON_ANIMATION_DURATION - set(value) { - firstAnimator.duration = value / 5 - secondAnimator.duration = value / 5 - thirdAnimator.duration = value / 5 - fourthAnimator.duration = value / 5 - fifthAnimator.duration = value / 5 - field = value - } - - var seconds: Int = 0 - set(value) { - binding.tvSeconds.text = context.resources.getQuantityString( - R.plurals.seconds, - value, - value - ) - field = value - } - - // Done as a field so that we don't have to compute on each tab if animations are enabled - private val animationsEnabled = DeviceUtils.hasAnimationsAnimatorDurationEnabled(context) - - val binding = PlayerFastSeekSecondsViewBinding.inflate(LayoutInflater.from(context), this) - - init { - orientation = VERTICAL - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - } - - fun setForwarding(isForward: Boolean) { - binding.triangleContainer.rotation = if (isForward) 0f else 180f - } - - fun startAnimation() { - stopAnimation() - - if (animationsEnabled) { - firstAnimator.start() - } else { - // If no animations are enable show the arrow(s) without animation - showWithoutAnimation() - } - } - - fun stopAnimation() { - firstAnimator.cancel() - secondAnimator.cancel() - thirdAnimator.cancel() - fourthAnimator.cancel() - fifthAnimator.cancel() - - reset() - } - - private fun reset() { - binding.icon1.alpha = 0f - binding.icon2.alpha = 0f - binding.icon3.alpha = 0f - } - - private fun showWithoutAnimation() { - binding.icon1.alpha = 1f - binding.icon2.alpha = 1f - binding.icon3.alpha = 1f - } - - private val firstAnimator: ValueAnimator = CustomValueAnimator( - { - binding.icon1.alpha = 0f - binding.icon2.alpha = 0f - binding.icon3.alpha = 0f - }, - { - binding.icon1.alpha = it - }, - { - secondAnimator.start() - } - ) - - private val secondAnimator: ValueAnimator = CustomValueAnimator( - { - binding.icon1.alpha = 1f - binding.icon2.alpha = 0f - binding.icon3.alpha = 0f - }, - { - binding.icon2.alpha = it - }, - { - thirdAnimator.start() - } - ) - - private val thirdAnimator: ValueAnimator = CustomValueAnimator( - { - binding.icon1.alpha = 1f - binding.icon2.alpha = 1f - binding.icon3.alpha = 0f - }, - { - binding.icon1.alpha = 1f - binding.icon3.alpha - binding.icon3.alpha = it - }, - { - fourthAnimator.start() - } - ) - - private val fourthAnimator: ValueAnimator = CustomValueAnimator( - { - binding.icon1.alpha = 0f - binding.icon2.alpha = 1f - binding.icon3.alpha = 1f - }, - { - binding.icon2.alpha = 1f - it - }, - { - fifthAnimator.start() - } - ) - - private val fifthAnimator: ValueAnimator = CustomValueAnimator( - { - binding.icon1.alpha = 0f - binding.icon2.alpha = 0f - binding.icon3.alpha = 1f - }, - { - binding.icon3.alpha = 1f - it - }, - { - firstAnimator.start() - } - ) - - private inner class CustomValueAnimator( - start: () -> Unit, - update: (value: Float) -> Unit, - end: () -> Unit - ) : ValueAnimator() { - - init { - duration = cycleDuration / 5 - setFloatValues(0f, 1f) - - addUpdateListener { update(it.animatedValue as Float) } - addListener( - onStart = { start() }, - onEnd = { end() } - ) - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java deleted file mode 100644 index 84e968b43..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ /dev/null @@ -1,208 +0,0 @@ -package us.shandian.giga.get; - -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.nio.channels.ClosedByInterruptException; - -import us.shandian.giga.util.Utility; - -import static org.schabi.newpipe.BuildConfig.DEBUG; -import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; - -public class DownloadInitializer extends Thread { - private static final String TAG = "DownloadInitializer"; - static final int mId = 0; - private static final int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB - private static final int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB - - private final DownloadMission mMission; - private HttpURLConnection mConn; - - DownloadInitializer(@NonNull DownloadMission mission) { - mMission = mission; - mConn = null; - } - - private void dispose() { - try { - mConn.getInputStream().close(); - } catch (Exception e) { - // nothing to do - } - } - - @Override - public void run() { - if (mMission.current > 0) mMission.resetState(false, true, DownloadMission.ERROR_NOTHING); - - int retryCount = 0; - int httpCode = 204; - - while (true) { - try { - if (mMission.blocks == null && mMission.current == 0) { - // calculate the whole size of the mission - long finalLength = 0; - long lowestSize = Long.MAX_VALUE; - - for (int i = 0; i < mMission.urls.length && mMission.running; i++) { - mConn = mMission.openConnection(mMission.urls[i], true, 0, 0); - mMission.establishConnection(mId, mConn); - dispose(); - - if (Thread.interrupted()) return; - long length = Utility.getTotalContentLength(mConn); - - if (i == 0) { - httpCode = mConn.getResponseCode(); - mMission.length = length; - } - - if (length > 0) finalLength += length; - if (length < lowestSize) lowestSize = length; - } - - mMission.nearLength = finalLength; - - // reserve space at the start of the file - if (mMission.psAlgorithm != null && mMission.psAlgorithm.reserveSpace) { - if (lowestSize < 1) { - // the length is unknown use the default size - mMission.offsets[0] = RESERVE_SPACE_DEFAULT; - } else { - // use the smallest resource size to download, otherwise, use the maximum - mMission.offsets[0] = lowestSize < RESERVE_SPACE_MAXIMUM ? lowestSize : RESERVE_SPACE_MAXIMUM; - } - } - } else { - // ask for the current resource length - mConn = mMission.openConnection(true, 0, 0); - mMission.establishConnection(mId, mConn); - dispose(); - - if (!mMission.running || Thread.interrupted()) return; - - httpCode = mConn.getResponseCode(); - mMission.length = Utility.getTotalContentLength(mConn); - } - - if (mMission.length == 0 || httpCode == 204) { - mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); - return; - } - - // check for dynamic generated content - if (mMission.length == -1 && mConn.getResponseCode() == 200) { - mMission.blocks = new int[0]; - mMission.length = 0; - mMission.unknownLength = true; - - if (DEBUG) { - Log.d(TAG, "falling back (unknown length)"); - } - } else { - // Open again - mConn = mMission.openConnection(true, mMission.length - 10, mMission.length); - mMission.establishConnection(mId, mConn); - dispose(); - - if (!mMission.running || Thread.interrupted()) return; - - synchronized (mMission.LOCK) { - if (mConn.getResponseCode() == 206) { - - if (mMission.threadCount > 1) { - int count = (int) (mMission.length / DownloadMission.BLOCK_SIZE); - if ((count * DownloadMission.BLOCK_SIZE) < mMission.length) count++; - - mMission.blocks = new int[count]; - } else { - // if one thread is required don't calculate blocks, is useless - mMission.blocks = new int[0]; - mMission.unknownLength = false; - } - - if (DEBUG) { - Log.d(TAG, "http response code = " + mConn.getResponseCode()); - } - } else { - // Fallback to single thread - mMission.blocks = new int[0]; - mMission.unknownLength = false; - - if (DEBUG) { - Log.d(TAG, "falling back due http response code = " + mConn.getResponseCode()); - } - } - } - - if (!mMission.running || Thread.interrupted()) return; - } - - try (SharpStream fs = mMission.storage.getStream()) { - fs.setLength(mMission.offsets[mMission.current] + mMission.length); - fs.seek(mMission.offsets[mMission.current]); - } - - if (!mMission.running || Thread.interrupted()) return; - - if (!mMission.unknownLength && mMission.recoveryInfo != null) { - String entityTag = mConn.getHeaderField("ETAG"); - String lastModified = mConn.getHeaderField("Last-Modified"); - MissionRecoveryInfo recovery = mMission.recoveryInfo[mMission.current]; - - if (!TextUtils.isEmpty(entityTag)) { - recovery.setValidateCondition(entityTag); - } else if (!TextUtils.isEmpty(lastModified)) { - recovery.setValidateCondition(lastModified);// Note: this is less precise - } else { - recovery.setValidateCondition(null); - } - } - - mMission.running = false; - break; - } catch (InterruptedIOException | ClosedByInterruptException e) { - return; - } catch (Exception e) { - if (!mMission.running || super.isInterrupted()) return; - - if (e instanceof DownloadMission.HttpError && ((DownloadMission.HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { - // for youtube streams. The url has expired - interrupt(); - mMission.doRecover(ERROR_HTTP_FORBIDDEN); - return; - } - - if (e instanceof IOException && e.getMessage().contains("Permission denied")) { - mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); - return; - } - - if (retryCount++ > mMission.maxRetry) { - Log.e(TAG, "initializer failed", e); - mMission.notifyError(e); - return; - } - - Log.e(TAG, "initializer failed, retrying", e); - } - } - - mMission.start(); - } - - @Override - public void interrupt() { - super.interrupt(); - if (mConn != null) dispose(); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java deleted file mode 100644 index 54340ce5d..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ /dev/null @@ -1,854 +0,0 @@ -package us.shandian.giga.get; - -import android.os.Handler; -import android.system.ErrnoException; -import android.system.OsConstants; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.DownloaderImpl; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.io.Serializable; -import java.net.ConnectException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.net.URL; -import java.net.UnknownHostException; -import java.nio.channels.ClosedByInterruptException; -import java.util.Objects; - -import javax.net.ssl.SSLException; - -import org.schabi.newpipe.streams.io.StoredFileHelper; -import us.shandian.giga.postprocessing.Postprocessing; -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.util.Utility; - -import static org.schabi.newpipe.BuildConfig.DEBUG; - -public class DownloadMission extends Mission { - private static final long serialVersionUID = 6L;// last bump: 07 october 2019 - - static final int BUFFER_SIZE = 64 * 1024; - static final int BLOCK_SIZE = 512 * 1024; - - private static final String TAG = "DownloadMission"; - - public static final int ERROR_NOTHING = -1; - public static final int ERROR_PATH_CREATION = 1000; - public static final int ERROR_FILE_CREATION = 1001; - public static final int ERROR_UNKNOWN_EXCEPTION = 1002; - public static final int ERROR_PERMISSION_DENIED = 1003; - public static final int ERROR_SSL_EXCEPTION = 1004; - public static final int ERROR_UNKNOWN_HOST = 1005; - public static final int ERROR_CONNECT_HOST = 1006; - public static final int ERROR_POSTPROCESSING = 1007; - public static final int ERROR_POSTPROCESSING_STOPPED = 1008; - public static final int ERROR_POSTPROCESSING_HOLD = 1009; - public static final int ERROR_INSUFFICIENT_STORAGE = 1010; - public static final int ERROR_PROGRESS_LOST = 1011; - public static final int ERROR_TIMEOUT = 1012; - public static final int ERROR_RESOURCE_GONE = 1013; - public static final int ERROR_HTTP_NO_CONTENT = 204; - static final int ERROR_HTTP_FORBIDDEN = 403; - - /** - * The urls of the file to download - */ - public String[] urls; - - /** - * Number of bytes downloaded and written - */ - public volatile long done; - - /** - * Indicates a file generated dynamically on the web server - */ - public boolean unknownLength; - - /** - * offset in the file where the data should be written - */ - public long[] offsets; - - /** - * Indicates if the post-processing state: - * 0: ready - * 1: running - * 2: completed - * 3: hold - */ - public volatile int psState; - - /** - * the post-processing algorithm instance - */ - public Postprocessing psAlgorithm; - - /** - * The current resource to download, {@code urls[current]} and {@code offsets[current]} - */ - public int current; - - /** - * Metadata where the mission state is saved - */ - public transient File metadata; - - /** - * maximum attempts - */ - public transient int maxRetry; - - /** - * Approximated final length, this represent the sum of all resources sizes - */ - public long nearLength; - - /** - * Download blocks, the size is multiple of {@link DownloadMission#BLOCK_SIZE}. - * Every entry (block) in this array holds an offset, used to resume the download. - * An block offset can be -1 if the block was downloaded successfully. - */ - int[] blocks; - - /** - * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} - */ - volatile long fallbackResumeOffset; - - /** - * Maximum of download threads running, chosen by the user - */ - public int threadCount = 3; - - /** - * information required to recover a download - */ - public MissionRecoveryInfo[] recoveryInfo; - - private transient int finishCount; - public transient volatile boolean running; - public boolean enqueued; - - public int errCode = ERROR_NOTHING; - public Exception errObject = null; - - public transient Handler mHandler; - private transient boolean[] blockAcquired; - - private transient long writingToFileNext; - private transient volatile boolean writingToFile; - - final Object LOCK = new Lock(); - - @NonNull - public transient Thread[] threads = new Thread[0]; - public transient Thread init = null; - - public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { - if (Objects.requireNonNull(urls).length < 1) - throw new IllegalArgumentException("urls array is empty"); - this.urls = urls; - this.kind = kind; - this.offsets = new long[urls.length]; - this.enqueued = true; - this.maxRetry = 3; - this.storage = storage; - this.psAlgorithm = psInstance; - - if (DEBUG && psInstance == null && urls.length > 1) { - Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); - } - } - - /** - * Acquire a block - * - * @return the block or {@code null} if no more blocks left - */ - @Nullable - Block acquireBlock() { - synchronized (LOCK) { - for (int i = 0; i < blockAcquired.length; i++) { - if (!blockAcquired[i] && blocks[i] >= 0) { - Block block = new Block(); - block.position = i; - block.done = blocks[i]; - - blockAcquired[i] = true; - return block; - } - } - } - - return null; - } - - /** - * Release an block - * - * @param position the index of the block - * @param done amount of bytes downloaded - */ - void releaseBlock(int position, int done) { - synchronized (LOCK) { - blockAcquired[position] = false; - blocks[position] = done; - } - } - - /** - * Opens a connection - * - * @param headRequest {@code true} for use {@code HEAD} request method, otherwise, {@code GET} is used - * @param rangeStart range start - * @param rangeEnd range end - * @return a {@link java.net.URLConnection URLConnection} linking to the URL. - * @throws IOException if an I/O exception occurs. - */ - HttpURLConnection openConnection(boolean headRequest, long rangeStart, long rangeEnd) throws IOException { - return openConnection(urls[current], headRequest, rangeStart, rangeEnd); - } - - HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); - conn.setInstanceFollowRedirects(true); - conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); - conn.setRequestProperty("Accept", "*/*"); - conn.setRequestProperty("Accept-Encoding", "*"); - - if (headRequest) conn.setRequestMethod("HEAD"); - - // BUG workaround: switching between networks can freeze the download forever - conn.setConnectTimeout(30000); - - if (rangeStart >= 0) { - String req = "bytes=" + rangeStart + "-"; - if (rangeEnd > 0) req += rangeEnd; - - conn.setRequestProperty("Range", req); - } - - return conn; - } - - /** - * @param threadId id of the calling thread - * @param conn Opens and establish the communication - * @throws IOException if an error occurred connecting to the server. - * @throws HttpError if the HTTP Status-Code is not satisfiable - */ - void establishConnection(int threadId, HttpURLConnection conn) throws IOException, HttpError { - int statusCode = conn.getResponseCode(); - - if (DEBUG) { - Log.d(TAG, threadId + ":[request] Range=" + conn.getRequestProperty("Range")); - Log.d(TAG, threadId + ":[response] Code=" + statusCode); - Log.d(TAG, threadId + ":[response] Content-Length=" + conn.getContentLength()); - Log.d(TAG, threadId + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")); - } - - - switch (statusCode) { - case 204: - case 205: - case 207: - throw new HttpError(statusCode); - case 416: - return;// let the download thread handle this error - default: - if (statusCode < 200 || statusCode > 299) { - throw new HttpError(statusCode); - } - } - - } - - - private void notify(int what) { - mHandler.obtainMessage(what, this).sendToTarget(); - } - - synchronized void notifyProgress(long deltaLen) { - if (unknownLength) { - length += deltaLen;// Update length before proceeding - } - - done += deltaLen; - - if (metadata == null) return; - - if (!writingToFile && (done > writingToFileNext || deltaLen < 0)) { - writingToFile = true; - writingToFileNext = done + BLOCK_SIZE; - writeThisToFileAsync(); - } - } - - synchronized void notifyError(Exception err) { - Log.e(TAG, "notifyError()", err); - - if (err instanceof FileNotFoundException) { - notifyError(ERROR_FILE_CREATION, null); - } else if (err instanceof SSLException) { - notifyError(ERROR_SSL_EXCEPTION, null); - } else if (err instanceof HttpError) { - notifyError(((HttpError) err).statusCode, null); - } else if (err instanceof ConnectException) { - notifyError(ERROR_CONNECT_HOST, null); - } else if (err instanceof UnknownHostException) { - notifyError(ERROR_UNKNOWN_HOST, null); - } else if (err instanceof SocketTimeoutException) { - notifyError(ERROR_TIMEOUT, null); - } else { - notifyError(ERROR_UNKNOWN_EXCEPTION, err); - } - } - - public synchronized void notifyError(int code, Exception err) { - Log.e(TAG, "notifyError() code = " + code, err); - if (err != null && err.getCause() instanceof ErrnoException) { - int errno = ((ErrnoException) err.getCause()).errno; - if (errno == OsConstants.ENOSPC) { - code = ERROR_INSUFFICIENT_STORAGE; - err = null; - } else if (errno == OsConstants.EACCES) { - code = ERROR_PERMISSION_DENIED; - err = null; - } - } - - if (err instanceof IOException) { - if (err.getMessage().contains("Permission denied")) { - code = ERROR_PERMISSION_DENIED; - err = null; - } else if (err.getMessage().contains("ENOSPC")) { - code = ERROR_INSUFFICIENT_STORAGE; - err = null; - } else if (!storage.canWrite()) { - code = ERROR_FILE_CREATION; - err = null; - } - } - - errCode = code; - errObject = err; - - switch (code) { - case ERROR_SSL_EXCEPTION: - case ERROR_UNKNOWN_HOST: - case ERROR_CONNECT_HOST: - case ERROR_TIMEOUT: - // do not change the queue flag for network errors, can be - // recovered silently without the user interaction - break; - default: - // also checks for server errors - if (code < 500 || code > 599) enqueued = false; - } - - notify(DownloadManagerService.MESSAGE_ERROR); - - if (running) pauseThreads(); - } - - synchronized void notifyFinished() { - if (current < urls.length) { - if (++finishCount < threads.length) return; - - if (DEBUG) { - Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.length); - } - - current++; - if (current < urls.length) { - // prepare next sub-mission - offsets[current] = offsets[current - 1] + length; - initializer(); - return; - } - } - - if (psAlgorithm != null && psState == 0) { - threads = new Thread[]{ - runAsync(1, this::doPostprocessing) - }; - return; - } - - - // this mission is fully finished - - unknownLength = false; - enqueued = false; - running = false; - - deleteThisFromFile(); - notify(DownloadManagerService.MESSAGE_FINISHED); - } - - private void notifyPostProcessing(int state) { - String action; - switch (state) { - case 1: - action = "Running"; - break; - case 2: - action = "Completed"; - break; - default: - action = "Failed"; - } - - Log.d(TAG, action + " postprocessing on " + storage.getName()); - - if (state == 2) { - psState = state; - return; - } - - synchronized (LOCK) { - // don't return without fully write the current state - psState = state; - writeThisToFile(); - } - } - - - /** - * Start downloading with multiple threads. - */ - public void start() { - if (running || isFinished() || urls.length < 1) return; - - // ensure that the previous state is completely paused. - joinForThreads(10000); - - running = true; - errCode = ERROR_NOTHING; - - if (hasInvalidStorage()) { - notifyError(ERROR_FILE_CREATION, null); - return; - } - - if (current >= urls.length) { - notifyFinished(); - return; - } - - notify(DownloadManagerService.MESSAGE_RUNNING); - - if (urls[current] == null) { - doRecover(ERROR_RESOURCE_GONE); - return; - } - - if (blocks == null) { - initializer(); - return; - } - - init = null; - finishCount = 0; - blockAcquired = new boolean[blocks.length]; - - if (blocks.length < 1) { - threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; - } else { - int remainingBlocks = 0; - for (int block : blocks) if (block >= 0) remainingBlocks++; - - if (remainingBlocks < 1) { - notifyFinished(); - return; - } - - threads = new Thread[Math.min(threadCount, remainingBlocks)]; - - for (int i = 0; i < threads.length; i++) { - threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); - } - } - } - - /** - * Pause the mission - */ - public void pause() { - if (!running) return; - - if (isPsRunning()) { - if (DEBUG) { - Log.w(TAG, "pause during post-processing is not applicable."); - } - return; - } - - running = false; - notify(DownloadManagerService.MESSAGE_PAUSED); - - if (init != null && init.isAlive()) { - // NOTE: if start() method is running ¡will no have effect! - init.interrupt(); - synchronized (LOCK) { - resetState(false, true, ERROR_NOTHING); - } - return; - } - - if (DEBUG && unknownLength) { - Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); - } - - init = null; - pauseThreads(); - } - - private void pauseThreads() { - running = false; - joinForThreads(-1); - writeThisToFile(); - } - - /** - * Removes the downloaded file and the meta file - */ - @Override - public boolean delete() { - if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); - - notify(DownloadManagerService.MESSAGE_DELETED); - - boolean res = deleteThisFromFile(); - - if (!super.delete()) return false; - return res; - } - - - /** - * Resets the mission state - * - * @param rollback {@code true} true to forget all progress, otherwise, {@code false} - * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} - */ - public void resetState(boolean rollback, boolean persistChanges, int errorCode) { - length = 0; - errCode = errorCode; - errObject = null; - unknownLength = false; - threads = new Thread[0]; - fallbackResumeOffset = 0; - blocks = null; - blockAcquired = null; - - if (rollback) current = 0; - if (persistChanges) writeThisToFile(); - } - - private void initializer() { - init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); - } - - private void writeThisToFileAsync() { - runAsync(-2, this::writeThisToFile); - } - - /** - * Write this {@link DownloadMission} to the meta file asynchronously - * if no thread is already running. - */ - void writeThisToFile() { - synchronized (LOCK) { - if (metadata == null) return; - Utility.writeToFile(metadata, this); - writingToFile = false; - } - } - - /** - * Indicates if the download if fully finished - * - * @return true, otherwise, false - */ - public boolean isFinished() { - return current >= urls.length && (psAlgorithm == null || psState == 2); - } - - /** - * Indicates if the download file is corrupt due a failed post-processing - * - * @return {@code true} if this mission is unrecoverable - */ - public boolean isPsFailed() { - switch (errCode) { - case ERROR_POSTPROCESSING: - case ERROR_POSTPROCESSING_STOPPED: - return psAlgorithm.worksOnSameFile; - } - - return false; - } - - /** - * Indicates if a post-processing algorithm is running - * - * @return true, otherwise, false - */ - public boolean isPsRunning() { - return psAlgorithm != null && (psState == 1 || psState == 3); - } - - /** - * Indicated if the mission is ready - * - * @return true, otherwise, false - */ - public boolean isInitialized() { - return blocks != null; // DownloadMissionInitializer was executed - } - - /** - * Gets the approximated final length of the file - * - * @return the length in bytes - */ - public long getLength() { - long calculated; - if (psState == 1 || psState == 3) { - return length; - } - - calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; - calculated -= offsets[0];// don't count reserved space - - return Math.max(calculated, nearLength); - } - - /** - * set this mission state on the queue - * - * @param queue true to add to the queue, otherwise, false - */ - public void setEnqueued(boolean queue) { - enqueued = queue; - writeThisToFileAsync(); - } - - /** - * Attempts to continue a blocked post-processing - * - * @param recover {@code true} to retry, otherwise, {@code false} to cancel - */ - public void psContinue(boolean recover) { - psState = 1; - errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; - threads[0].interrupt(); - } - - /** - * Indicates whatever the backed storage is invalid - * - * @return {@code true}, if storage is invalid and cannot be used - */ - public boolean hasInvalidStorage() { - // Don't consider ERROR_PROGRESS_LOST as invalid storage - it can be recovered - return storage == null || !storage.existsAsFile(); - } - - /** - * Indicates whatever is possible to start the mission - * - * @return {@code true} is this mission its "healthy", otherwise, {@code false} - */ - public boolean isCorrupt() { - if (urls.length < 1) return false; - return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished(); - } - - /** - * Indicates if mission urls has expired and there an attempt to renovate them - * - * @return {@code true} if the mission is running a recovery procedure, otherwise, {@code false} - */ - public boolean isRecovering() { - return threads.length > 0 && threads[0] instanceof DownloadMissionRecover && threads[0].isAlive(); - } - - private void doPostprocessing() { - errCode = ERROR_NOTHING; - errObject = null; - Thread thread = Thread.currentThread(); - - notifyPostProcessing(1); - - if (DEBUG) { - thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage.getName()); - } - - Exception exception = null; - - try { - psAlgorithm.run(this); - } catch (Exception err) { - Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); - - if (err instanceof InterruptedIOException || err instanceof ClosedByInterruptException || thread.isInterrupted()) { - notifyError(DownloadMission.ERROR_POSTPROCESSING_STOPPED, null); - return; - } - - if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; - - exception = err; - } finally { - notifyPostProcessing(errCode == ERROR_NOTHING ? 2 : 0); - } - - if (errCode != ERROR_NOTHING) { - if (exception == null) exception = errObject; - notifyError(ERROR_POSTPROCESSING, exception); - return; - } - - notifyFinished(); - } - - /** - * Attempts to recover the download - * - * @param errorCode error code which trigger the recovery procedure - */ - void doRecover(int errorCode) { - Log.i(TAG, "Attempting to recover the mission: " + storage.getName()); - - if (recoveryInfo == null) { - notifyError(errorCode, null); - urls = new String[0];// mark this mission as dead - return; - } - - joinForThreads(0); - - threads = new Thread[]{ - runAsync(DownloadMissionRecover.mID, new DownloadMissionRecover(this, errorCode)) - }; - } - - private boolean deleteThisFromFile() { - synchronized (LOCK) { - boolean res = metadata.delete(); - metadata = null; - return res; - } - } - - /** - * run a new thread - * - * @param id id of new thread (used for debugging only) - * @param who the Runnable whose {@code run} method is invoked. - */ - private Thread runAsync(int id, Runnable who) { - return runAsync(id, new Thread(who)); - } - - /** - * run a new thread - * - * @param id id of new thread (used for debugging only) - * @param who the Thread whose {@code run} method is invoked when this thread is started - * @return the passed thread - */ - private Thread runAsync(int id, Thread who) { - // known thread ids: - // -2: state saving by notifyProgress() method - // -1: wait for saving the state by pause() method - // 0: initializer - // >=1: any download thread - - if (DEBUG) { - who.setName(String.format("%s[%s] %s", TAG, id, storage.getName())); - } - - who.start(); - - return who; - } - - /** - * Waits at most {@code millis} milliseconds for the thread to die - * - * @param millis the time to wait in milliseconds - */ - private void joinForThreads(int millis) { - final Thread currentThread = Thread.currentThread(); - - if (init != null && init != currentThread && init.isAlive()) { - init.interrupt(); - - if (millis > 0) { - try { - init.join(millis); - } catch (InterruptedException e) { - Log.w(TAG, "Initializer thread is still running", e); - return; - } - } - } - - // if a thread is still alive, possible reasons: - // slow device - // the user is spamming start/pause buttons - // start() method called quickly after pause() - - for (Thread thread : threads) { - if (!thread.isAlive() || thread == Thread.currentThread()) continue; - thread.interrupt(); - } - - try { - for (Thread thread : threads) { - if (!thread.isAlive()) continue; - if (DEBUG) { - Log.w(TAG, "thread alive: " + thread.getName()); - } - if (millis > 0) thread.join(millis); - } - } catch (InterruptedException e) { - throw new RuntimeException("A download thread is still running", e); - } - } - - - static class HttpError extends Exception { - final int statusCode; - - HttpError(int statusCode) { - this.statusCode = statusCode; - } - - @Override - public String getMessage() { - return "HTTP " + statusCode; - } - } - - public static class Block { - public int position; - public int done; - } - - private static class Lock implements Serializable { - // java.lang.Object cannot be used because is not serializable - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java deleted file mode 100644 index e001c6f3f..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java +++ /dev/null @@ -1,321 +0,0 @@ -package us.shandian.giga.get; - -import android.util.Log; - -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; -import org.schabi.newpipe.extractor.stream.StreamExtractor; -import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; - -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.nio.channels.ClosedByInterruptException; -import java.util.List; - -import us.shandian.giga.get.DownloadMission.HttpError; - -import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; - -public class DownloadMissionRecover extends Thread { - private static final String TAG = "DownloadMissionRecover"; - static final int mID = -3; - - private final DownloadMission mMission; - private final boolean mNotInitialized; - - private final int mErrCode; - - private HttpURLConnection mConn; - private MissionRecoveryInfo mRecovery; - private StreamExtractor mExtractor; - - DownloadMissionRecover(DownloadMission mission, int errCode) { - mMission = mission; - mNotInitialized = mission.blocks == null && mission.current == 0; - mErrCode = errCode; - } - - @Override - public void run() { - if (mMission.source == null) { - mMission.notifyError(mErrCode, null); - return; - } - - Exception err = null; - int attempt = 0; - - while (attempt++ < mMission.maxRetry) { - try { - tryRecover(); - return; - } catch (InterruptedIOException | ClosedByInterruptException e) { - return; - } catch (Exception e) { - if (!mMission.running || super.isInterrupted()) return; - err = e; - } - } - - // give up - mMission.notifyError(mErrCode, err); - } - - private void tryRecover() throws ExtractionException, IOException, HttpError { - if (mExtractor == null) { - try { - StreamingService svr = NewPipe.getServiceByUrl(mMission.source); - mExtractor = svr.getStreamExtractor(mMission.source); - mExtractor.fetchPage(); - } catch (ExtractionException e) { - mExtractor = null; - throw e; - } - } - - // maybe the following check is redundant - if (!mMission.running || super.isInterrupted()) return; - - if (!mNotInitialized) { - // set the current download url to null in case if the recovery - // process is canceled. Next time start() method is called the - // recovery will be executed, saving time - mMission.urls[mMission.current] = null; - - mRecovery = mMission.recoveryInfo[mMission.current]; - resolveStream(); - return; - } - - Log.w(TAG, "mission is not fully initialized, this will take a while"); - - try { - for (; mMission.current < mMission.urls.length; mMission.current++) { - mRecovery = mMission.recoveryInfo[mMission.current]; - - if (test()) continue; - if (!mMission.running) return; - - resolveStream(); - if (!mMission.running) return; - - // before continue, check if the current stream was resolved - if (mMission.urls[mMission.current] == null) { - break; - } - } - } finally { - mMission.current = 0; - } - - mMission.writeThisToFile(); - - if (!mMission.running || super.isInterrupted()) return; - - mMission.running = false; - mMission.start(); - } - - private void resolveStream() throws IOException, ExtractionException, HttpError { - // FIXME: this getErrorMessage() always returns "video is unavailable" - /*if (mExtractor.getErrorMessage() != null) { - mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage())); - return; - }*/ - - String url = null; - - switch (mRecovery.getKind()) { - case 'a': - for (final AudioStream audio : mExtractor.getAudioStreams()) { - if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() - && audio.getFormat() == mRecovery.getFormat() - && audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { - url = audio.getContent(); - break; - } - } - break; - case 'v': - final List videoStreams; - if (mRecovery.isDesired2()) - videoStreams = mExtractor.getVideoOnlyStreams(); - else - videoStreams = mExtractor.getVideoStreams(); - for (final VideoStream video : videoStreams) { - if (video.getResolution().equals(mRecovery.getDesired()) - && video.getFormat() == mRecovery.getFormat() - && video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { - url = video.getContent(); - break; - } - } - break; - case 's': - for (final SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery - .getFormat())) { - String tag = subtitles.getLanguageTag(); - if (tag.equals(mRecovery.getDesired()) - && subtitles.isAutoGenerated() == mRecovery.isDesired2() - && subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { - url = subtitles.getContent(); - break; - } - } - break; - default: - throw new RuntimeException("Unknown stream type"); - } - - resolve(url); - } - - private void resolve(String url) throws IOException, HttpError { - if (mRecovery.getValidateCondition() == null) { - Log.w(TAG, "validation condition not defined, the resource can be stale"); - } - - if (mMission.unknownLength || mRecovery.getValidateCondition() == null) { - recover(url, false); - return; - } - - /////////////////////////////////////////////////////////////////////// - ////// Validate the http resource doing a range request - ///////////////////// - try { - mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length); - mConn.setRequestProperty("If-Range", mRecovery.getValidateCondition()); - mMission.establishConnection(mID, mConn); - - int code = mConn.getResponseCode(); - - switch (code) { - case 200: - case 413: - // stale - recover(url, true); - return; - case 206: - // in case of validation using the Last-Modified date, check the resource length - long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range")); - boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length; - - recover(url, lengthMismatch); - return; - } - - throw new HttpError(code); - } finally { - disconnect(); - } - } - - private void recover(String url, boolean stale) { - Log.i(TAG, - String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url) - ); - - mMission.urls[mMission.current] = url; - - if (url == null) { - mMission.urls = new String[0]; - mMission.notifyError(ERROR_RESOURCE_GONE, null); - return; - } - - if (mNotInitialized) return; - - if (stale) { - mMission.resetState(false, false, DownloadMission.ERROR_NOTHING); - } - - mMission.writeThisToFile(); - - if (!mMission.running || super.isInterrupted()) return; - - mMission.running = false; - mMission.start(); - } - - private long[] parseContentRange(String value) { - long[] range = new long[3]; - - if (value == null) { - // this never should happen - return range; - } - - try { - value = value.trim(); - - if (!value.startsWith("bytes")) { - return range;// unknown range type - } - - int space = value.lastIndexOf(' ') + 1; - int dash = value.indexOf('-', space) + 1; - int bar = value.indexOf('/', dash); - - // start - range[0] = Long.parseLong(value.substring(space, dash - 1)); - - // end - range[1] = Long.parseLong(value.substring(dash, bar)); - - // resource length - value = value.substring(bar + 1); - if (value.equals("*")) { - range[2] = -1;// unknown length received from the server but should be valid - } else { - range[2] = Long.parseLong(value); - } - } catch (Exception e) { - // nothing to do - } - - return range; - } - - private boolean test() { - if (mMission.urls[mMission.current] == null) return false; - - try { - mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1); - mMission.establishConnection(mID, mConn); - - if (mConn.getResponseCode() == 200) return true; - } catch (Exception e) { - // nothing to do - } finally { - disconnect(); - } - - return false; - } - - private void disconnect() { - try { - try { - mConn.getInputStream().close(); - } finally { - mConn.disconnect(); - } - } catch (Exception e) { - // nothing to do - } finally { - mConn = null; - } - } - - @Override - public void interrupt() { - super.interrupt(); - if (mConn != null) disconnect(); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java deleted file mode 100644 index 6f504cea3..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ /dev/null @@ -1,184 +0,0 @@ -package us.shandian.giga.get; - -import android.util.Log; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.nio.channels.ClosedByInterruptException; -import java.util.Objects; - -import us.shandian.giga.get.DownloadMission.Block; -import us.shandian.giga.get.DownloadMission.HttpError; - -import static org.schabi.newpipe.BuildConfig.DEBUG; -import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; - - -/** - * Runnable to download blocks of a file until the file is completely downloaded, - * an error occurs or the process is stopped. - */ -public class DownloadRunnable extends Thread { - private static final String TAG = "DownloadRunnable"; - - private final DownloadMission mMission; - private final int mId; - - private HttpURLConnection mConn; - - DownloadRunnable(DownloadMission mission, int id) { - mMission = Objects.requireNonNull(mission); - mId = id; - } - - private void releaseBlock(Block block, long remain) { - // set the block offset to -1 if it is completed - mMission.releaseBlock(block.position, remain < 0 ? -1 : block.done); - } - - @Override - public void run() { - boolean retry = false; - Block block = null; - int retryCount = 0; - SharpStream f; - - try { - f = mMission.storage.getStream(); - } catch (IOException e) { - mMission.notifyError(e);// this never should happen - return; - } - - while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING) { - if (!retry) { - block = mMission.acquireBlock(); - } - - if (block == null) { - if (DEBUG) Log.d(TAG, mId + ":no more blocks left, exiting"); - break; - } - - if (DEBUG) { - if (retry) - Log.d(TAG, mId + ":retry block at position=" + block.position + " from the start"); - else - Log.d(TAG, mId + ":acquired block at position=" + block.position + " done=" + block.done); - } - - long start = (long)block.position * DownloadMission.BLOCK_SIZE; - long end = start + DownloadMission.BLOCK_SIZE - 1; - - start += block.done; - - if (end >= mMission.length) { - end = mMission.length - 1; - } - - try { - mConn = mMission.openConnection(false, start, end); - mMission.establishConnection(mId, mConn); - - // check if the download can be resumed - if (mConn.getResponseCode() == 416) { - if (block.done > 0) { - // try again from the start (of the block) - mMission.notifyProgress(-block.done); - block.done = 0; - retry = true; - mConn.disconnect(); - continue; - } - - throw new DownloadMission.HttpError(416); - } - - retry = false; - - // The server may be ignoring the range request - if (mConn.getResponseCode() != 206) { - if (DEBUG) { - Log.e(TAG, mId + ":Unsupported " + mConn.getResponseCode()); - } - mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); - break; - } - - f.seek(mMission.offsets[mMission.current] + start); - - try (InputStream is = mConn.getInputStream()) { - byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; - int len; - - // use always start <= end - // fixes a deadlock because in some videos, youtube is sending one byte alone - while (start <= end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { - f.write(buf, 0, len); - start += len; - block.done += len; - mMission.notifyProgress(len); - } - } - - if (DEBUG && mMission.running) { - Log.d(TAG, mId + ":position " + block.position + " stopped " + start + "/" + end); - } - } catch (Exception e) { - if (!mMission.running || e instanceof ClosedByInterruptException) break; - - if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { - // for youtube streams. The url has expired, recover - f.close(); - - if (mId == 1) { - // only the first thread will execute the recovery procedure - mMission.doRecover(ERROR_HTTP_FORBIDDEN); - } - return; - } - - if (retryCount++ >= mMission.maxRetry) { - mMission.notifyError(e); - break; - } - - retry = true; - } finally { - if (!retry) releaseBlock(block, end - start); - } - } - - f.close(); - - if (DEBUG) { - Log.d(TAG, "thread " + mId + " exited from main download loop"); - } - - if (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running) { - if (DEBUG) { - Log.d(TAG, "no error has happened, notifying"); - } - mMission.notifyFinished(); - } - - if (DEBUG && !mMission.running) { - Log.d(TAG, "The mission has been paused. Passing."); - } - } - - @Override - public void interrupt() { - super.interrupt(); - - try { - if (mConn != null) mConn.disconnect(); - } catch (Exception e) { - // nothing to do - } - } - -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java deleted file mode 100644 index b79ff59c3..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ /dev/null @@ -1,156 +0,0 @@ -package us.shandian.giga.get; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.nio.channels.ClosedByInterruptException; - -import us.shandian.giga.get.DownloadMission.HttpError; -import us.shandian.giga.util.Utility; - -import static org.schabi.newpipe.BuildConfig.DEBUG; -import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; - -/** - * Single-threaded fallback mode - */ -public class DownloadRunnableFallback extends Thread { - private static final String TAG = "DLRunnableFallback"; - - private final DownloadMission mMission; - - private int mRetryCount = 0; - private InputStream mIs; - private SharpStream mF; - private HttpURLConnection mConn; - - DownloadRunnableFallback(@NonNull DownloadMission mission) { - mMission = mission; - } - - private void dispose() { - try { - try { - if (mIs != null) mIs.close(); - } finally { - mConn.disconnect(); - } - } catch (IOException e) { - // nothing to do - } - - if (mF != null) mF.close(); - } - - @Override - public void run() { - boolean done; - long start = mMission.fallbackResumeOffset; - - if (DEBUG && !mMission.unknownLength && start > 0) { - Log.i(TAG, "Resuming a single-thread download at " + start); - } - - try { - long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; - - int mId = 1; - mConn = mMission.openConnection(false, rangeStart, -1); - - if (mRetryCount == 0 && rangeStart == -1) { - // workaround: bypass android connection pool - mConn.setRequestProperty("Range", "bytes=0-"); - } - - mMission.establishConnection(mId, mConn); - - // check if the download can be resumed - if (mConn.getResponseCode() == 416 && start > 0) { - mMission.notifyProgress(-start); - start = 0; - mRetryCount--; - throw new DownloadMission.HttpError(416); - } - - // secondary check for the file length - if (!mMission.unknownLength) - mMission.unknownLength = Utility.getContentLength(mConn) == -1; - - if (mMission.unknownLength || mConn.getResponseCode() == 200) { - // restart amount of bytes downloaded - mMission.done = mMission.offsets[mMission.current] - mMission.offsets[0]; - start = 0; // reset position to avoid writing at wrong offset - } - - mF = mMission.storage.getStream(); - mF.seek(mMission.offsets[mMission.current] + start); - - mIs = mConn.getInputStream(); - - byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; - int len = 0; - - while (mMission.running && (len = mIs.read(buf, 0, buf.length)) != -1) { - mF.write(buf, 0, len); - start += len; - mMission.notifyProgress(len); - } - - dispose(); - - // if thread goes interrupted check if the last part is written. This avoid re-download the whole file - done = len == -1; - } catch (Exception e) { - dispose(); - - mMission.fallbackResumeOffset = start; - - if (!mMission.running || e instanceof ClosedByInterruptException) return; - - if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { - // for youtube streams. The url has expired, recover - dispose(); - mMission.doRecover(ERROR_HTTP_FORBIDDEN); - return; - } - - if (mRetryCount++ >= mMission.maxRetry) { - mMission.notifyError(e); - return; - } - - if (DEBUG) { - Log.e(TAG, "got exception, retrying...", e); - } - - run();// try again - return; - } - - if (done) { - mMission.notifyFinished(); - } else { - mMission.fallbackResumeOffset = start; - } - } - - @Override - public void interrupt() { - super.interrupt(); - - if (mConn != null) { - try { - mConn.disconnect(); - } catch (Exception e) { - // nothing to do - } - - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java deleted file mode 100644 index 29f3c6296..000000000 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ /dev/null @@ -1,18 +0,0 @@ -package us.shandian.giga.get; - -import androidx.annotation.NonNull; - -public class FinishedMission extends Mission { - - public FinishedMission() { - } - - public FinishedMission(@NonNull DownloadMission mission) { - source = mission.source; - length = mission.length; - timestamp = mission.timestamp; - kind = mission.kind; - storage = mission.storage; - } - -} diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java deleted file mode 100644 index 77b9c1e33..000000000 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ /dev/null @@ -1,64 +0,0 @@ -package us.shandian.giga.get; - -import androidx.annotation.NonNull; - -import java.io.Serializable; -import java.util.Calendar; - -import org.schabi.newpipe.streams.io.StoredFileHelper; - -public abstract class Mission implements Serializable { - private static final long serialVersionUID = 1L;// last bump: 27 march 2019 - - /** - * Source url of the resource - */ - public String source; - - /** - * Length of the current resource - */ - public long length; - - /** - * creation timestamp (and maybe unique identifier) - */ - public long timestamp; - - public long getTimestamp() { - return timestamp; - } - - /** - * pre-defined content type - */ - public char kind; - - /** - * The downloaded file - */ - public StoredFileHelper storage; - - /** - * Delete the downloaded file - * - * @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false} - */ - public boolean delete() { - if (storage != null) return storage.delete(); - return true; - } - - /** - * Indicate if this mission is deleted whatever is stored - */ - public transient boolean deleted = false; - - @NonNull - @Override - public String toString() { - final Calendar calendar = Calendar.getInstance(); - calendar.setTimeInMillis(timestamp); - return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt deleted file mode 100644 index c145b8506..000000000 --- a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt +++ /dev/null @@ -1,77 +0,0 @@ -package us.shandian.giga.get - -import android.os.Parcelable -import java.io.Serializable -import kotlinx.parcelize.Parcelize -import org.schabi.newpipe.extractor.MediaFormat -import org.schabi.newpipe.extractor.stream.AudioStream -import org.schabi.newpipe.extractor.stream.Stream -import org.schabi.newpipe.extractor.stream.SubtitlesStream -import org.schabi.newpipe.extractor.stream.VideoStream - -@Parcelize -class MissionRecoveryInfo( - var format: MediaFormat?, - var desired: String? = null, - var isDesired2: Boolean = false, - var desiredBitrate: Int = 0, - var kind: Char = Char.MIN_VALUE, - var validateCondition: String? = null -) : Serializable, Parcelable { - constructor(stream: Stream) : this(format = stream.format) { - when (stream) { - is AudioStream -> { - desiredBitrate = stream.getAverageBitrate() - isDesired2 = false - kind = 'a' - } - - is VideoStream -> { - desired = stream.getResolution() - isDesired2 = stream.isVideoOnly() - kind = 'v' - } - - is SubtitlesStream -> { - desired = stream.languageTag - isDesired2 = stream.isAutoGenerated - kind = 's' - } - - else -> throw RuntimeException("Unknown stream kind") - } - } - - override fun toString(): String { - val info: String - val str = StringBuilder() - str.append("{type=") - when (kind) { - 'a' -> { - str.append("audio") - info = "bitrate=$desiredBitrate" - } - - 'v' -> { - str.append("video") - info = "quality=$desired videoOnly=$isDesired2" - } - - 's' -> { - str.append("subtitles") - info = "language=$desired autoGenerated=$isDesired2" - } - - else -> { - info = "" - str.append("other") - } - } - str.append(" format=") - .append(format?.getName()) - .append(' ') - .append(info) - .append('}') - return str.toString() - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java deleted file mode 100644 index 215a6553b..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ /dev/null @@ -1,244 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; - -import java.io.File; -import java.util.ArrayList; -import java.util.Objects; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; -import org.schabi.newpipe.streams.io.StoredFileHelper; - -/** - * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s - */ -public class FinishedMissionStore extends SQLiteOpenHelper { - - // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) - private static final String DATABASE_NAME = "downloads.db"; - - private static final int DATABASE_VERSION = 4; - - /** - * The table name of download missions (old) - */ - private static final String MISSIONS_TABLE_NAME_v2 = "download_missions"; - - /** - * The table name of download missions - */ - private static final String FINISHED_TABLE_NAME = "finished_missions"; - - /** - * The key to the urls of a mission - */ - private static final String KEY_SOURCE = "url"; - - - /** - * The key to the done. - */ - private static final String KEY_DONE = "bytes_downloaded"; - - private static final String KEY_TIMESTAMP = "timestamp"; - - private static final String KEY_KIND = "kind"; - - private static final String KEY_PATH = "path"; - - /** - * The statement to create the table - */ - private static final String MISSIONS_CREATE_TABLE = - "CREATE TABLE " + FINISHED_TABLE_NAME + " (" + - KEY_PATH + " TEXT NOT NULL, " + - KEY_SOURCE + " TEXT NOT NULL, " + - KEY_DONE + " INTEGER NOT NULL, " + - KEY_TIMESTAMP + " INTEGER NOT NULL, " + - KEY_KIND + " TEXT NOT NULL, " + - " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; - - - private final Context context; - - public FinishedMissionStore(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - this.context = context; - } - - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(MISSIONS_CREATE_TABLE); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion == 2) { - db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;"); - oldVersion++; - } - - if (oldVersion == 3) { - final String KEY_LOCATION = "location"; - final String KEY_NAME = "name"; - - db.execSQL(MISSIONS_CREATE_TABLE); - - Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, - null, null, null, KEY_TIMESTAMP); - - int count = cursor.getCount(); - if (count > 0) { - db.beginTransaction(); - while (cursor.moveToNext()) { - ContentValues values = new ContentValues(); - values.put( - KEY_SOURCE, - cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)) - ); - values.put( - KEY_DONE, - cursor.getString(cursor.getColumnIndexOrThrow(KEY_DONE)) - ); - values.put( - KEY_TIMESTAMP, - cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)) - ); - values.put(KEY_KIND, cursor.getString(cursor.getColumnIndexOrThrow(KEY_KIND))); - values.put(KEY_PATH, Uri.fromFile( - new File( - cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)), - cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)) - ) - ).toString()); - - db.insert(FINISHED_TABLE_NAME, null, values); - } - db.setTransactionSuccessful(); - db.endTransaction(); - } - - cursor.close(); - db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); - } - } - - /** - * Returns all values of the download mission as ContentValues. - * - * @param downloadMission the download mission - * @return the content values - */ - private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { - ContentValues values = new ContentValues(); - values.put(KEY_SOURCE, downloadMission.source); - values.put(KEY_PATH, downloadMission.storage.getUri().toString()); - values.put(KEY_DONE, downloadMission.length); - values.put(KEY_TIMESTAMP, downloadMission.timestamp); - values.put(KEY_KIND, String.valueOf(downloadMission.kind)); - return values; - } - - private FinishedMission getMissionFromCursor(Cursor cursor) { - String kind = Objects.requireNonNull(cursor) - .getString(cursor.getColumnIndexOrThrow(KEY_KIND)); - if (kind == null || kind.isEmpty()) kind = "?"; - - String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)); - - FinishedMission mission = new FinishedMission(); - - mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)); - mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); - mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); - mission.kind = kind.charAt(0); - - try { - mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); - } catch (Exception e) { - Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e); - mission.storage = new StoredFileHelper(null, path, "", ""); - } - - return mission; - } - - - ////////////////////////////////// - // Data source methods - /////////////////////////////////// - - public ArrayList loadFinishedMissions() { - SQLiteDatabase database = getReadableDatabase(); - Cursor cursor = database.query(FINISHED_TABLE_NAME, null, null, - null, null, null, KEY_TIMESTAMP + " DESC"); - - int count = cursor.getCount(); - if (count == 0) return new ArrayList<>(1); - - ArrayList result = new ArrayList<>(count); - while (cursor.moveToNext()) { - result.add(getMissionFromCursor(cursor)); - } - - return result; - } - - public void addFinishedMission(DownloadMission downloadMission) { - ContentValues values = getValuesOfMission(Objects.requireNonNull(downloadMission)); - SQLiteDatabase database = getWritableDatabase(); - database.insert(FINISHED_TABLE_NAME, null, values); - } - - public void deleteMission(Mission mission) { - String ts = String.valueOf(Objects.requireNonNull(mission).timestamp); - - SQLiteDatabase database = getWritableDatabase(); - - if (mission instanceof FinishedMission) { - if (mission.storage.isInvalid()) { - database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); - } else { - database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ - ts, mission.storage.getUri().toString() - }); - } - } else { - throw new UnsupportedOperationException("DownloadMission"); - } - } - - public void updateMission(Mission mission) { - ContentValues values = getValuesOfMission(Objects.requireNonNull(mission)); - SQLiteDatabase database = getWritableDatabase(); - String ts = String.valueOf(mission.timestamp); - - int rowsAffected; - - if (mission instanceof FinishedMission) { - if (mission.storage.isInvalid()) { - rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts}); - } else { - rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ - mission.storage.getUri().toString() - }); - } - } else { - throw new UnsupportedOperationException("DownloadMission"); - } - - if (rowsAffected != 1) { - Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java deleted file mode 100644 index f7edf3975..000000000 --- a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java +++ /dev/null @@ -1,155 +0,0 @@ -package us.shandian.giga.io; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -public class ChunkFileInputStream extends SharpStream { - private static final int REPORT_INTERVAL = 256 * 1024; - - private SharpStream source; - private final long offset; - private final long length; - private long position; - - private long progressReport; - private final ProgressReport onProgress; - - public ChunkFileInputStream(SharpStream target, long start, long end, ProgressReport callback) throws IOException { - source = target; - offset = start; - length = end - start; - position = 0; - onProgress = callback; - progressReport = REPORT_INTERVAL; - - if (length < 1) { - source.close(); - throw new IOException("The chunk is empty or invalid"); - } - if (source.length() < end) { - try { - throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); - } finally { - source.close(); - } - } - - source.seek(offset); - } - - /** - * Get absolute position on file - * - * @return the position - */ - public long getFilePointer() { - return offset + position; - } - - @Override - public int read() throws IOException { - if ((position + 1) > length) { - return 0; - } - - int res = source.read(); - if (res >= 0) { - position++; - } - - return res; - } - - @Override - public int read(byte[] b) throws IOException { - return read(b, 0, b.length); - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - if ((position + len) > length) { - len = (int) (length - position); - } - if (len == 0) { - return 0; - } - - int res = source.read(b, off, len); - position += res; - - if (onProgress != null && position > progressReport) { - onProgress.report(position); - progressReport = position + REPORT_INTERVAL; - } - - return res; - } - - @Override - public long skip(long pos) throws IOException { - pos = Math.min(pos + position, length); - - if (pos == 0) { - return 0; - } - - source.seek(offset + pos); - - long oldPos = position; - position = pos; - - return pos - oldPos; - } - - @Override - public long available() { - return length - position; - } - - @SuppressWarnings("EmptyCatchBlock") - @Override - public void close() { - source.close(); - source = null; - } - - @Override - public boolean isClosed() { - return source == null; - } - - @Override - public void rewind() throws IOException { - position = 0; - source.seek(offset); - } - - @Override - public boolean canRewind() { - return true; - } - - @Override - public boolean canRead() { - return true; - } - - @Override - public boolean canWrite() { - return false; - } - - @Override - public void write(byte value) { - } - - @Override - public void write(byte[] buffer) { - } - - @Override - public void write(byte[] buffer, int offset, int count) { - } - -} diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java deleted file mode 100644 index 4473fa7f9..000000000 --- a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java +++ /dev/null @@ -1,486 +0,0 @@ -package us.shandian.giga.io; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.Objects; - -public class CircularFileWriter extends SharpStream { - - private static final int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB - private static final int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB - private static final int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB - private static final int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB - - private final OffsetChecker callback; - - public ProgressReport onProgress; - public WriteErrorHandle onWriteError; - - private long reportPosition; - private long maxLengthKnown = -1; - - private BufferedFile out; - private BufferedFile aux; - - public CircularFileWriter(SharpStream target, File temp, OffsetChecker checker) throws IOException { - Objects.requireNonNull(checker); - - if (!temp.exists()) { - if (!temp.createNewFile()) { - throw new IOException("Cannot create a temporal file"); - } - } - - aux = new BufferedFile(temp); - out = new BufferedFile(target); - - callback = checker; - - reportPosition = NOTIFY_BYTES_INTERVAL; - } - - private void flushAuxiliar(long amount) throws IOException { - if (aux.length < 1) { - return; - } - - out.flush(); - aux.flush(); - - boolean underflow = aux.offset < aux.length || out.offset < out.length; - byte[] buffer = new byte[COPY_BUFFER_SIZE]; - - aux.target.seek(0); - out.target.seek(out.length); - - long length = amount; - while (length > 0) { - int read = (int) Math.min(length, Integer.MAX_VALUE); - read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); - - if (read < 1) { - amount -= length; - break; - } - - out.writeProof(buffer, read); - length -= read; - } - - if (underflow) { - if (out.offset >= out.length) { - // calculate the aux underflow pointer - if (aux.offset < amount) { - out.offset += aux.offset; - aux.offset = 0; - out.target.seek(out.offset); - } else { - aux.offset -= amount; - out.offset = out.length + amount; - } - } else { - aux.offset = 0; - } - } else { - out.offset += amount; - aux.offset -= amount; - } - - out.length += amount; - - if (out.length > maxLengthKnown) { - maxLengthKnown = out.length; - } - - if (amount < aux.length) { - // move the excess data to the beginning of the file - long readOffset = amount; - long writeOffset = 0; - - aux.length -= amount; - length = aux.length; - while (length > 0) { - int read = (int) Math.min(length, Integer.MAX_VALUE); - read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); - - aux.target.seek(writeOffset); - aux.writeProof(buffer, read); - - writeOffset += read; - readOffset += read; - length -= read; - - aux.target.seek(readOffset); - } - - aux.target.setLength(aux.length); - return; - } - - if (aux.length > THRESHOLD_AUX_LENGTH) { - aux.target.setLength(THRESHOLD_AUX_LENGTH);// or setLength(0); - } - - aux.reset(); - } - - /** - * Flush any buffer and close the output file. Use this method if the - * operation is successful - * - * @return the final length of the file - * @throws IOException if an I/O error occurs - */ - public long finalizeFile() throws IOException { - flushAuxiliar(aux.length); - - out.flush(); - - // change file length (if required) - long length = Math.max(maxLengthKnown, out.length); - if (length != out.target.length()) { - out.target.setLength(length); - } - - close(); - - return length; - } - - /** - * Close the file without flushing any buffer - */ - @Override - public void close() { - if (out != null) { - out.close(); - out = null; - } - if (aux != null) { - aux.close(); - aux = null; - } - } - - @Override - public void write(byte b) throws IOException { - write(new byte[]{b}, 0, 1); - } - - @Override - public void write(byte[] b) throws IOException { - write(b, 0, b.length); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - if (len == 0) { - return; - } - - long available; - long offsetOut = out.getOffset(); - long offsetAux = aux.getOffset(); - long end = callback.check(); - - if (end == -1) { - available = Integer.MAX_VALUE; - } else if (end < offsetOut) { - throw new IOException("The reported offset is invalid: " + end + "<" + offsetOut); - } else { - available = end - offsetOut; - } - - boolean usingAux = aux.length > 0 && offsetOut >= out.length; - boolean underflow = offsetAux < aux.length || offsetOut < out.length; - - if (usingAux) { - // before continue calculate the final length of aux - long length = offsetAux + len; - if (underflow) { - if (aux.length > length) { - length = aux.length;// the length is not changed - } - } else { - length = aux.length + len; - } - - aux.write(b, off, len); - - if (length >= THRESHOLD_AUX_LENGTH && length <= available) { - flushAuxiliar(available); - } - } else { - if (underflow) { - available = out.length - offsetOut; - } - - int length = Math.min(len, (int) Math.min(Integer.MAX_VALUE, available)); - out.write(b, off, length); - - len -= length; - off += length; - - if (len > 0) { - aux.write(b, off, len); - } - } - - if (onProgress != null) { - long absoluteOffset = out.getOffset() + aux.getOffset(); - if (absoluteOffset > reportPosition) { - reportPosition = absoluteOffset + NOTIFY_BYTES_INTERVAL; - onProgress.report(absoluteOffset); - } - } - } - - @Override - public void flush() throws IOException { - aux.flush(); - out.flush(); - - long total = out.length + aux.length; - if (total > maxLengthKnown) { - maxLengthKnown = total;// save the current file length in case the method {@code rewind()} is called - } - } - - @Override - public long skip(long amount) throws IOException { - seek(out.getOffset() + aux.getOffset() + amount); - return amount; - } - - @Override - public void rewind() throws IOException { - if (onProgress != null) { - onProgress.report(0);// rollback the whole progress - } - - seek(0); - - reportPosition = NOTIFY_BYTES_INTERVAL; - } - - @Override - public void seek(long offset) throws IOException { - long total = out.length + aux.length; - - if (offset == total) { - // do not ignore the seek offset if a underflow exists - long relativeOffset = out.getOffset() + aux.getOffset(); - if (relativeOffset == total) { - return; - } - } - - // flush everything, avoid any underflow - flush(); - - if (offset < 0 || offset > total) { - throw new IOException("desired offset is outside of range=0-" + total + " offset=" + offset); - } - - if (offset > out.length) { - out.seek(out.length); - aux.seek(offset - out.length); - } else { - out.seek(offset); - aux.seek(0); - } - } - - @Override - public boolean isClosed() { - return out == null; - } - - @Override - public boolean canRewind() { - return true; - } - - @Override - public boolean canWrite() { - return true; - } - - @Override - public boolean canSeek() { - return true; - } - - // - @Override - public boolean canRead() { - return false; - } - - @Override - public int read() { - throw new UnsupportedOperationException("write-only"); - } - - @Override - public int read(byte[] buffer - ) { - throw new UnsupportedOperationException("write-only"); - } - - @Override - public int read(byte[] buffer, int offset, int count - ) { - throw new UnsupportedOperationException("write-only"); - } - - @Override - public long available() { - throw new UnsupportedOperationException("write-only"); - } - // - - public interface OffsetChecker { - - /** - * Checks the amount of available space ahead - * - * @return absolute offset in the file where no more data SHOULD NOT be - * written. If the value is -1 the whole file will be used - */ - long check(); - } - - public interface WriteErrorHandle { - - /** - * Attempts to handle a I/O exception - * - * @param err the cause - * @return {@code true} to retry and continue, otherwise, {@code false} - * and throw the exception - */ - boolean handle(Exception err); - } - - class BufferedFile { - - final SharpStream target; - - private long offset; - long length; - - private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; - private int queueSize; - - BufferedFile(File file) throws FileNotFoundException { - this.target = new FileStream(file); - } - - BufferedFile(SharpStream target) { - this.target = target; - } - - long getOffset() { - return offset + queueSize;// absolute offset in the file - } - - void close() { - queue = null; - target.close(); - } - - void write(byte[] b, int off, int len) throws IOException { - while (len > 0) { - // if the queue is full, the method available() will flush the queue - int read = Math.min(available(), len); - - // enqueue incoming buffer - System.arraycopy(b, off, queue, queueSize, read); - queueSize += read; - - len -= read; - off += read; - } - - long total = offset + queueSize; - if (total > length) { - length = total;// save length - } - } - - void flush() throws IOException { - writeProof(queue, queueSize); - offset += queueSize; - queueSize = 0; - } - - protected void rewind() throws IOException { - offset = 0; - target.seek(0); - } - - int available() throws IOException { - if (queueSize >= queue.length) { - flush(); - return queue.length; - } - - return queue.length - queueSize; - } - - void reset() throws IOException { - offset = 0; - length = 0; - target.seek(0); - } - - void seek(long absoluteOffset) throws IOException { - if (absoluteOffset == offset) { - return;// nothing to do - } - offset = absoluteOffset; - target.seek(absoluteOffset); - } - - void writeProof(byte[] buffer, int length) throws IOException { - if (onWriteError == null) { - target.write(buffer, 0, length); - return; - } - - while (true) { - try { - target.write(buffer, 0, length); - return; - } catch (Exception e) { - if (!onWriteError.handle(e)) { - throw e;// give up - } - } - } - } - - @NonNull - @Override - public String toString() { - String absLength; - - try { - absLength = Long.toString(target.length()); - } catch (IOException e) { - absLength = "[" + e.getLocalizedMessage() + "]"; - } - - return String.format( - "offset=%s length=%s queue=%s absLength=%s", - offset, length, queueSize, absLength - ); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/io/FileStream.java b/app/src/main/java/us/shandian/giga/io/FileStream.java deleted file mode 100644 index bbc56b20c..000000000 --- a/app/src/main/java/us/shandian/giga/io/FileStream.java +++ /dev/null @@ -1,131 +0,0 @@ -package us.shandian.giga.io; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.RandomAccessFile; - -/** - * @author kapodamy - */ -public class FileStream extends SharpStream { - - public RandomAccessFile source; - - public FileStream(@NonNull File target) throws FileNotFoundException { - this.source = new RandomAccessFile(target, "rw"); - } - - public FileStream(@NonNull String path) throws FileNotFoundException { - this.source = new RandomAccessFile(path, "rw"); - } - - @Override - public int read() throws IOException { - return source.read(); - } - - @Override - public int read(byte[] b) throws IOException { - return source.read(b); - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - return source.read(b, off, len); - } - - @Override - public long skip(long pos) throws IOException { - return source.skipBytes((int) pos); - } - - @Override - public long available() { - try { - return source.length() - source.getFilePointer(); - } catch (IOException e) { - return 0; - } - } - - @Override - public void close() { - if (source == null) return; - try { - source.close(); - } catch (IOException err) { - // nothing to do - } - source = null; - } - - @Override - public boolean isClosed() { - return source == null; - } - - @Override - public void rewind() throws IOException { - source.seek(0); - } - - @Override - public boolean canRewind() { - return true; - } - - @Override - public boolean canRead() { - return true; - } - - @Override - public boolean canWrite() { - return true; - } - - @Override - public boolean canSeek() { - return true; - } - - @Override - public boolean canSetLength() { - return true; - } - - @Override - public void write(byte value) throws IOException { - source.write(value); - } - - @Override - public void write(byte[] buffer) throws IOException { - source.write(buffer); - } - - @Override - public void write(byte[] buffer, int offset, int count) throws IOException { - source.write(buffer, offset, count); - } - - @Override - public void setLength(long length) throws IOException { - source.setLength(length); - } - - @Override - public void seek(long offset) throws IOException { - source.seek(offset); - } - - @Override - public long length() throws IOException { - return source.length(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java deleted file mode 100644 index b7dd0a103..000000000 --- a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java +++ /dev/null @@ -1,150 +0,0 @@ -package us.shandian.giga.io; - -import android.content.ContentResolver; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.channels.FileChannel; - -public class FileStreamSAF extends SharpStream { - - private final FileInputStream in; - private final FileOutputStream out; - private final FileChannel channel; - private final ParcelFileDescriptor file; - - private boolean disposed; - - public FileStreamSAF(@NonNull ContentResolver contentResolver, Uri fileUri) throws IOException { - // Notes: - // the file must exists first - // ¡read-write mode must allow seek! - // It is not guaranteed to work with files in the cloud (virtual files), tested in local storage devices - - file = contentResolver.openFileDescriptor(fileUri, "rw"); - - if (file == null) { - throw new IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()); - } - - in = new FileInputStream(file.getFileDescriptor()); - out = new FileOutputStream(file.getFileDescriptor()); - channel = out.getChannel();// or use in.getChannel() - } - - @Override - public int read() throws IOException { - return in.read(); - } - - @Override - public int read(byte[] buffer) throws IOException { - return in.read(buffer); - } - - @Override - public int read(byte[] buffer, int offset, int count) throws IOException { - return in.read(buffer, offset, count); - } - - @Override - public long skip(long amount) throws IOException { - return in.skip(amount);// ¿or use channel.position(channel.position() + amount)? - } - - @Override - public long available() { - try { - return in.available(); - } catch (IOException e) { - return 0;// ¡but not -1! - } - } - - @Override - public void rewind() throws IOException { - seek(0); - } - - @Override - public void close() { - try { - disposed = true; - - file.close(); - in.close(); - out.close(); - channel.close(); - } catch (IOException e) { - Log.e("FileStreamSAF", "close() error", e); - } - } - - @Override - public boolean isClosed() { - return disposed; - } - - @Override - public boolean canRewind() { - return true; - } - - @Override - public boolean canRead() { - return true; - } - - @Override - public boolean canWrite() { - return true; - } - - @Override - public boolean canSetLength() { - return true; - } - - @Override - public boolean canSeek() { - return true; - } - - @Override - public void write(byte value) throws IOException { - out.write(value); - } - - @Override - public void write(byte[] buffer) throws IOException { - out.write(buffer); - } - - @Override - public void write(byte[] buffer, int offset, int count) throws IOException { - out.write(buffer, offset, count); - } - - @Override - public void setLength(long length) throws IOException { - channel.truncate(length); - } - - @Override - public void seek(long offset) throws IOException { - channel.position(offset); - } - - @Override - public long length() throws IOException { - return channel.size(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/ProgressReport.java b/app/src/main/java/us/shandian/giga/io/ProgressReport.java deleted file mode 100644 index e382747f6..000000000 --- a/app/src/main/java/us/shandian/giga/io/ProgressReport.java +++ /dev/null @@ -1,11 +0,0 @@ -package us.shandian.giga.io; - -public interface ProgressReport { - - /** - * Report the size of the new file - * - * @param progress the new size - */ - void report(long progress); -} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java deleted file mode 100644 index aa5170908..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ /dev/null @@ -1,41 +0,0 @@ -package us.shandian.giga.postprocessing; - -import org.schabi.newpipe.streams.Mp4DashReader; -import org.schabi.newpipe.streams.Mp4FromDashWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -class M4aNoDash extends Postprocessing { - - M4aNoDash() { - super(false, true, ALGORITHM_M4A_NO_DASH); - } - - @Override - boolean test(SharpStream... sources) throws IOException { - // check if the mp4 file is DASH (youtube) - - Mp4DashReader reader = new Mp4DashReader(sources[0]); - reader.parse(); - - switch (reader.getBrands()[0]) { - case 0x64617368:// DASH - case 0x69736F35:// ISO5 - return true; - default: - return false; - } - } - - @Override - int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources[0]); - muxer.setMainBrand(0x4D344120);// binary string "M4A " - muxer.parseSources(); - muxer.selectTracks(0); - muxer.build(out); - - return OK_RESULT; - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java deleted file mode 100644 index 74cb43116..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ /dev/null @@ -1,27 +0,0 @@ -package us.shandian.giga.postprocessing; - -import org.schabi.newpipe.streams.Mp4FromDashWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -/** - * @author kapodamy - */ -class Mp4FromDashMuxer extends Postprocessing { - - Mp4FromDashMuxer() { - super(true, true, ALGORITHM_MP4_FROM_DASH_MUXER); - } - - @Override - int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources); - muxer.parseSources(); - muxer.selectTracks(0, 0); - muxer.build(out); - - return OK_RESULT; - } - -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java deleted file mode 100644 index badb5f7ed..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java +++ /dev/null @@ -1,44 +0,0 @@ -package us.shandian.giga.postprocessing; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.OggFromWebMWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.nio.ByteBuffer; - -class OggFromWebmDemuxer extends Postprocessing { - - OggFromWebmDemuxer() { - super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER); - } - - @Override - boolean test(SharpStream... sources) throws IOException { - ByteBuffer buffer = ByteBuffer.allocate(4); - sources[0].read(buffer.array()); - - // youtube uses WebM as container, but the file extension (format suffix) is "*.opus" - // check if the file is a webm/mkv file before proceed - - switch (buffer.getInt()) { - case 0x1a45dfa3: - return true;// webm/mkv - case 0x4F676753: - return false;// ogg - } - - throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream"); - } - - @Override - int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { - OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo); - demuxer.parseSource(); - demuxer.selectTrack(0); - demuxer.build(); - - return OK_RESULT; - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java deleted file mode 100644 index 1c9143252..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ /dev/null @@ -1,261 +0,0 @@ -package us.shandian.giga.postprocessing; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.io.ChunkFileInputStream; -import us.shandian.giga.io.CircularFileWriter; -import us.shandian.giga.io.CircularFileWriter.OffsetChecker; -import us.shandian.giga.io.ProgressReport; - -import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; -import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; -import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; - -public abstract class Postprocessing implements Serializable { - - static transient final byte OK_RESULT = ERROR_NOTHING; - - public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; - public transient static final String ALGORITHM_WEBM_MUXER = "webm"; - public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; - public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; - public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; - - public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, - StreamInfo streamInfo) { - Postprocessing instance; - - switch (algorithmName) { - case ALGORITHM_TTML_CONVERTER: - instance = new TtmlConverter(); - break; - case ALGORITHM_WEBM_MUXER: - instance = new WebMMuxer(); - break; - case ALGORITHM_MP4_FROM_DASH_MUXER: - instance = new Mp4FromDashMuxer(); - break; - case ALGORITHM_M4A_NO_DASH: - instance = new M4aNoDash(); - break; - case ALGORITHM_OGG_FROM_WEBM_DEMUXER: - instance = new OggFromWebmDemuxer(); - break; - /*case "example-algorithm": - instance = new ExampleAlgorithm();*/ - default: - throw new UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName); - } - - instance.args = args; - instance.streamInfo = streamInfo; - return instance; - } - - /** - * Get a boolean value that indicate if the given algorithm work on the same - * file - */ - public boolean worksOnSameFile; - - /** - * Indicates whether the selected algorithm needs space reserved at the beginning of the file - */ - public boolean reserveSpace; - - /** - * Gets the given algorithm short name - */ - private final String name; - - private String[] args; - protected StreamInfo streamInfo; - - private transient DownloadMission mission; - - private transient File tempFile; - - Postprocessing(boolean reserveSpace, boolean worksOnSameFile, String algorithmName) { - this.reserveSpace = reserveSpace; - this.worksOnSameFile = worksOnSameFile; - this.name = algorithmName;// for debugging only - } - - public void setTemporalDir(@NonNull File directory) { - long rnd = (int) (Math.random() * 100000.0f); - tempFile = new File(directory, rnd + "_" + System.nanoTime() + ".tmp"); - } - - public void cleanupTemporalDir() { - if (tempFile != null && tempFile.exists()) { - try { - //noinspection ResultOfMethodCallIgnored - tempFile.delete(); - } catch (Exception e) { - // nothing to do - } - } - } - - - public void run(DownloadMission target) throws IOException { - this.mission = target; - - int result; - long finalLength = -1; - - mission.done = 0; - - long length = mission.storage.length() - mission.offsets[0]; - mission.length = Math.max(length, mission.nearLength); - - final ProgressReport readProgress = (long position) -> { - position -= mission.offsets[0]; - if (position > mission.done) mission.done = position; - }; - - if (worksOnSameFile) { - ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; - try { - for (int i = 0, j = 1; i < sources.length; i++, j++) { - SharpStream source = mission.storage.getStream(); - long end = j < sources.length ? mission.offsets[j] : source.length(); - - sources[i] = new ChunkFileInputStream(source, mission.offsets[i], end, readProgress); - } - - if (test(sources)) { - for (SharpStream source : sources) source.rewind(); - - OffsetChecker checker = () -> { - for (ChunkFileInputStream source : sources) { - /* - * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) - * or the CircularFileWriter can lead to unexpected results - */ - if (source.isClosed() || source.available() < 1) { - continue;// the selected source is not used anymore - } - - return source.getFilePointer() - 1; - } - - return -1; - }; - - try (CircularFileWriter out = new CircularFileWriter( - mission.storage.getStream(), tempFile, checker)) { - out.onProgress = (long position) -> mission.done = position; - - out.onWriteError = err -> { - mission.psState = 3; - mission.notifyError(ERROR_POSTPROCESSING_HOLD, err); - - try { - synchronized (this) { - while (mission.psState == 3) - wait(); - } - } catch (InterruptedException e) { - // nothing to do - Log.e(getClass().getSimpleName(), "got InterruptedException"); - } - - return mission.errCode == ERROR_NOTHING; - }; - - result = process(out, sources); - - if (result == OK_RESULT) - finalLength = out.finalizeFile(); - } - } else { - result = OK_RESULT; - } - } finally { - for (SharpStream source : sources) { - if (source != null && !source.isClosed()) { - source.close(); - } - } - if (tempFile != null) { - //noinspection ResultOfMethodCallIgnored - tempFile.delete(); - tempFile = null; - } - } - } else { - result = test() ? process(null) : OK_RESULT; - } - - if (result == OK_RESULT) { - if (finalLength != -1) { - mission.length = finalLength; - } - } else { - mission.errCode = ERROR_POSTPROCESSING; - mission.errObject = new RuntimeException("post-processing algorithm returned " + result); - } - - if (result != OK_RESULT && worksOnSameFile) mission.storage.delete(); - - this.mission = null; - } - - /** - * Test if the post-processing algorithm can be skipped - * - * @param sources files to be processed - * @return {@code true} if the post-processing is required, otherwise, {@code false} - * @throws IOException if an I/O error occurs. - */ - boolean test(SharpStream... sources) throws IOException { - return true; - } - - /** - * Abstract method to execute the post-processing algorithm - * - * @param out output stream - * @param sources files to be processed - * @return an error code, {@code OK_RESULT} means the operation was successful - * @throws IOException if an I/O error occurs. - */ - abstract int process(SharpStream out, SharpStream... sources) throws IOException; - - String getArgumentAt(int index, String defaultValue) { - if (args == null || index >= args.length) { - return defaultValue; - } - - return args[index]; - } - - @NonNull - @Override - public String toString() { - StringBuilder str = new StringBuilder(); - - str.append("{ name=").append(name).append('['); - - if (args != null) { - for (String arg : args) { - str.append(", "); - str.append(arg); - } - str.delete(0, 1); - } - - return str.append("] }").toString(); - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java deleted file mode 100644 index d723bfb45..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java +++ /dev/null @@ -1,53 +0,0 @@ -package us.shandian.giga.postprocessing; - -import android.util.Log; - -import org.schabi.newpipe.streams.SrtFromTtmlWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -/** - * @author kapodamy - */ -class TtmlConverter extends Postprocessing { - private static final String TAG = "TtmlConverter"; - - TtmlConverter() { - // due how XmlPullParser works, the xml is fully loaded on the ram - super(false, true, ALGORITHM_TTML_CONVERTER); - } - - @Override - int process(SharpStream out, SharpStream... sources) throws IOException { - // check if the subtitle is already in srt and copy, this should never happen - String format = getArgumentAt(0, null); - boolean ignoreEmptyFrames = getArgumentAt(1, "true").equals("true"); - - if (format == null || format.equals("ttml")) { - SrtFromTtmlWriter writer = new SrtFromTtmlWriter(out, ignoreEmptyFrames); - - try { - writer.build(sources[0]); - } catch (IOException err) { - Log.e(TAG, "subtitle conversion failed due to I/O error", err); - throw err; - } catch (Exception err) { - Log.e(TAG, "subtitle conversion failed", err); - throw new IOException("TTML to SRT conversion failed", err); - } - - return OK_RESULT; - } else if (format.equals("srt")) { - byte[] buffer = new byte[8 * 1024]; - int read; - while ((read = sources[0].read(buffer)) > 0) { - out.write(buffer, 0, read); - } - return OK_RESULT; - } - - throw new UnsupportedOperationException("Can't convert this subtitle, unimplemented format: " + format); - } - -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java deleted file mode 100644 index ea1676482..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java +++ /dev/null @@ -1,44 +0,0 @@ -package us.shandian.giga.postprocessing; - -import org.schabi.newpipe.streams.WebMReader.TrackKind; -import org.schabi.newpipe.streams.WebMReader.WebMTrack; -import org.schabi.newpipe.streams.WebMWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -/** - * @author kapodamy - */ -class WebMMuxer extends Postprocessing { - - WebMMuxer() { - super(true, true, ALGORITHM_WEBM_MUXER); - } - - @Override - int process(SharpStream out, SharpStream... sources) throws IOException { - WebMWriter muxer = new WebMWriter(sources); - muxer.parseSources(); - - // youtube uses a webm with a fake video track that acts as a "cover image" - int[] indexes = new int[sources.length]; - - for (int i = 0; i < sources.length; i++) { - WebMTrack[] tracks = muxer.getTracksFromSource(i); - for (int j = 0; j < tracks.length; j++) { - if (tracks[j].kind == TrackKind.Audio) { - indexes[i] = j; - i = sources.length; - break; - } - } - } - - muxer.selectTracks(indexes); - muxer.build(out); - - return OK_RESULT; - } - -} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java deleted file mode 100644 index 7a2055aaa..000000000 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ /dev/null @@ -1,754 +0,0 @@ -package us.shandian.giga.service; - -import android.content.Context; -import android.os.Handler; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.DiffUtil; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; -import us.shandian.giga.get.sqlite.FinishedMissionStore; -import org.schabi.newpipe.streams.io.StoredDirectoryHelper; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import us.shandian.giga.util.Utility; - -import static org.schabi.newpipe.BuildConfig.DEBUG; -import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; -import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; - -public class DownloadManager { - private static final String TAG = DownloadManager.class.getSimpleName(); - - enum NetworkState {Unavailable, Operating, MeteredOperating} - - public static final int SPECIAL_NOTHING = 0; - public static final int SPECIAL_PENDING = 1; - public static final int SPECIAL_FINISHED = 2; - - public static final String TAG_AUDIO = "audio"; - public static final String TAG_VIDEO = "video"; - private static final String DOWNLOADS_METADATA_FOLDER = "pending_downloads"; - - private final FinishedMissionStore mFinishedMissionStore; - - private final ArrayList mMissionsPending = new ArrayList<>(); - private final ArrayList mMissionsFinished; - - private final Handler mHandler; - private final File mPendingMissionsDir; - - private NetworkState mLastNetworkStatus = NetworkState.Unavailable; - - int mPrefMaxRetry; - boolean mPrefMeteredDownloads; - boolean mPrefQueueLimit; - private boolean mSelfMissionsControl; - - StoredDirectoryHelper mMainStorageAudio; - StoredDirectoryHelper mMainStorageVideo; - - /** - * Create a new instance - * - * @param context Context for the data source for finished downloads - * @param handler Thread required for Messaging - */ - DownloadManager(@NonNull Context context, Handler handler, StoredDirectoryHelper storageVideo, StoredDirectoryHelper storageAudio) { - if (DEBUG) { - Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); - } - - mFinishedMissionStore = new FinishedMissionStore(context); - mHandler = handler; - mMainStorageAudio = storageAudio; - mMainStorageVideo = storageVideo; - mMissionsFinished = loadFinishedMissions(); - mPendingMissionsDir = getPendingDir(context); - - loadPendingMissions(context); - } - - private static File getPendingDir(@NonNull Context context) { - File dir = context.getExternalFilesDir(DOWNLOADS_METADATA_FOLDER); - if (testDir(dir)) return dir; - - dir = new File(context.getFilesDir(), DOWNLOADS_METADATA_FOLDER); - if (testDir(dir)) return dir; - - throw new RuntimeException("path to pending downloads are not accessible"); - } - - private static boolean testDir(@Nullable File dir) { - if (dir == null) return false; - - try { - if (!Utility.mkdir(dir, false)) { - Log.e(TAG, "testDir() cannot create the directory in path: " + dir.getAbsolutePath()); - return false; - } - - File tmp = new File(dir, ".tmp"); - if (!tmp.createNewFile()) return false; - return tmp.delete();// if the file was created, SHOULD BE deleted too - } catch (Exception e) { - Log.e(TAG, "testDir() failed: " + dir.getAbsolutePath(), e); - return false; - } - } - - /** - * Loads finished missions from the data source and forgets finished missions whose file does - * not exist anymore. - */ - private ArrayList loadFinishedMissions() { - ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); - - // check if the files exists, otherwise, forget the download - for (int i = finishedMissions.size() - 1; i >= 0; i--) { - FinishedMission mission = finishedMissions.get(i); - - if (!mission.storage.existsAsFile()) { - if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()); - - mFinishedMissionStore.deleteMission(mission); - finishedMissions.remove(i); - } - } - - return finishedMissions; - } - - private void loadPendingMissions(Context ctx) { - File[] subs = mPendingMissionsDir.listFiles(); - - if (subs == null) { - Log.e(TAG, "listFiles() returned null"); - return; - } - if (subs.length < 1) { - return; - } - if (DEBUG) { - Log.d(TAG, "Loading pending downloads from directory: " + mPendingMissionsDir.getAbsolutePath()); - } - - File tempDir = pickAvailableTemporalDir(ctx); - Log.i(TAG, "using '" + tempDir + "' as temporal directory"); - - for (File sub : subs) { - if (!sub.isFile()) continue; - if (sub.getName().equals(".tmp")) continue; - - DownloadMission mis = Utility.readFromFile(sub); - if (mis == null) { - //noinspection ResultOfMethodCallIgnored - sub.delete(); - continue; - } - - // DON'T delete missions that are truly finished - let them be moved to finished list - if (mis.isFinished()) { - // Move to finished missions instead of deleting - setFinished(mis); - //noinspection ResultOfMethodCallIgnored - sub.delete(); - continue; - } - - // DON'T delete missions with storage issues - try to recover them - if (mis.hasInvalidStorage() && mis.errCode != ERROR_PROGRESS_LOST) { - // Only delete if it's truly unrecoverable (not just progress lost) - if (mis.storage == null) { - //noinspection ResultOfMethodCallIgnored - sub.delete(); - continue; - } - } - - mis.threads = new Thread[0]; - - boolean exists; - try { - mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); - exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); - } catch (Exception ex) { - Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex); - // Don't invalidate storage immediately - try to recover first - exists = false; - } - - if (mis.isPsRunning()) { - if (mis.psAlgorithm.worksOnSameFile) { - // Incomplete post-processing results in a corrupted download file - if (exists && mis.storage.isDirect() && !mis.storage.delete()) - Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); - } - - mis.psState = 0; - mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; - } else if (!exists) { - tryRecover(mis); - // Keep the mission even if recovery fails - don't reset to ERROR_PROGRESS_LOST - // This allows user to see the failed download and potentially retry - if (mis.isInitialized() && mis.errCode == ERROR_NOTHING) { - mis.resetState(true, true, ERROR_PROGRESS_LOST); - } - } - - if (mis.psAlgorithm != null) { - mis.psAlgorithm.cleanupTemporalDir(); - mis.psAlgorithm.setTemporalDir(tempDir); - } - - mis.metadata = sub; - mis.maxRetry = mPrefMaxRetry; - mis.mHandler = mHandler; - - mMissionsPending.add(mis); - } - - if (mMissionsPending.size() > 1) - Collections.sort(mMissionsPending, Comparator.comparingLong(Mission::getTimestamp)); - } - - /** - * Start a new download mission - * - * @param mission the new download mission to add and run (if possible) - */ - void startMission(DownloadMission mission) { - synchronized (this) { - mission.timestamp = System.currentTimeMillis(); - mission.mHandler = mHandler; - mission.maxRetry = mPrefMaxRetry; - - // create metadata file - while (true) { - mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); - if (!mission.metadata.isFile() && !mission.metadata.exists()) { - try { - if (!mission.metadata.createNewFile()) - throw new RuntimeException("Cant create download metadata file"); - } catch (IOException e) { - throw new RuntimeException(e); - } - break; - } - mission.timestamp = System.currentTimeMillis(); - } - - mSelfMissionsControl = true; - mMissionsPending.add(mission); - - // Before continue, save the metadata in case the internet connection is not available - Utility.writeToFile(mission.metadata, mission); - - if (mission.storage == null) { - // noting to do here - mission.errCode = DownloadMission.ERROR_FILE_CREATION; - if (mission.errObject != null) - mission.errObject = new IOException("DownloadMission.storage == NULL"); - return; - } - - boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; - - if (canDownloadInCurrentNetwork() && start) { - mission.start(); - } - } - } - - - public void resumeMission(DownloadMission mission) { - if (!mission.running) { - mission.start(); - } - } - - public void pauseMission(DownloadMission mission) { - if (mission.running) { - mission.setEnqueued(false); - mission.pause(); - } - } - - public void deleteMission(Mission mission, boolean alsoDeleteFile) { - synchronized (this) { - if (mission instanceof DownloadMission) { - mMissionsPending.remove(mission); - } else if (mission instanceof FinishedMission) { - mMissionsFinished.remove(mission); - mFinishedMissionStore.deleteMission(mission); - } - - if (alsoDeleteFile) { - mission.delete(); - } - } - } - - public void forgetMission(StoredFileHelper storage) { - synchronized (this) { - Mission mission = getAnyMission(storage); - if (mission == null) return; - - if (mission instanceof DownloadMission) { - mMissionsPending.remove(mission); - } else if (mission instanceof FinishedMission) { - mMissionsFinished.remove(mission); - mFinishedMissionStore.deleteMission(mission); - } - - mission.storage = null; - mission.delete(); - } - } - - public void tryRecover(DownloadMission mission) { - StoredDirectoryHelper mainStorage = getMainStorage(mission.storage.getTag()); - - if (!mission.storage.isInvalid() && mission.storage.create()) return; - - // using javaIO cannot recreate the file - // using SAF in older devices (no tree available) - // - // force the user to pick again the save path - mission.storage.invalidate(); - - if (mainStorage == null) return; - - // if the user has changed the save path before this download, the original save path will be lost - StoredFileHelper newStorage = mainStorage.createFile(mission.storage.getName(), mission.storage.getType()); - - if (newStorage != null) mission.storage = newStorage; - } - - - /** - * Get a pending mission by its path - * - * @param storage where the file possible is stored - * @return the mission or null if no such mission exists - */ - @Nullable - private DownloadMission getPendingMission(StoredFileHelper storage) { - for (DownloadMission mission : mMissionsPending) { - if (mission.storage.equals(storage)) { - return mission; - } - } - return null; - } - - /** - * Get the index into {@link #mMissionsFinished} of a finished mission by its path, return - * {@code -1} if there is no such mission. This function also checks if the matched mission's - * file exists, and, if it does not, the related mission is forgotten about (like in {@link - * #loadFinishedMissions()}) and {@code -1} is returned. - * - * @param storage where the file would be stored - * @return the mission index or -1 if no such mission exists - */ - private int getFinishedMissionIndex(StoredFileHelper storage) { - for (int i = 0; i < mMissionsFinished.size(); i++) { - if (mMissionsFinished.get(i).storage.equals(storage)) { - // If the file does not exist the mission is not valid anymore. Also checking if - // length == 0 since the file picker may create an empty file before yielding it, - // but that does not mean the file really belonged to a previous mission. - if (!storage.existsAsFile() || storage.length() == 0) { - if (DEBUG) { - Log.d(TAG, "matched downloaded file removed: " + storage.getName()); - } - - mFinishedMissionStore.deleteMission(mMissionsFinished.get(i)); - mMissionsFinished.remove(i); - return -1; // finished mission whose associated file was removed - } - return i; - } - } - - return -1; - } - - private Mission getAnyMission(StoredFileHelper storage) { - synchronized (this) { - Mission mission = getPendingMission(storage); - if (mission != null) return mission; - - int idx = getFinishedMissionIndex(storage); - if (idx >= 0) return mMissionsFinished.get(idx); - } - - return null; - } - - int getRunningMissionsCount() { - int count = 0; - synchronized (this) { - for (DownloadMission mission : mMissionsPending) { - if (mission.running && !mission.isPsFailed() && !mission.isFinished()) - count++; - } - } - - return count; - } - - public void pauseAllMissions(boolean force) { - synchronized (this) { - for (DownloadMission mission : mMissionsPending) { - if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue; - - if (force) { - // avoid waiting for threads - mission.init = null; - mission.threads = new Thread[0]; - } - - mission.pause(); - } - } - } - - public void startAllMissions() { - synchronized (this) { - for (DownloadMission mission : mMissionsPending) { - if (mission.running || mission.isCorrupt()) continue; - - mission.start(); - } - } - } - - /** - * Set a pending download as finished - * - * @param mission the desired mission - */ - void setFinished(DownloadMission mission) { - synchronized (this) { - mMissionsPending.remove(mission); - mMissionsFinished.add(0, new FinishedMission(mission)); - mFinishedMissionStore.addFinishedMission(mission); - } - } - - /** - * runs one or multiple missions in from queue if possible - * - * @return true if one or multiple missions are running, otherwise, false - */ - boolean runMissions() { - synchronized (this) { - if (mMissionsPending.size() < 1) return false; - if (!canDownloadInCurrentNetwork()) return false; - - if (mPrefQueueLimit) { - for (DownloadMission mission : mMissionsPending) - if (!mission.isFinished() && mission.running) return true; - } - - boolean flag = false; - for (DownloadMission mission : mMissionsPending) { - if (mission.running || !mission.enqueued || mission.isFinished()) - continue; - - resumeMission(mission); - if (mission.errCode != ERROR_NOTHING) continue; - - if (mPrefQueueLimit) return true; - flag = true; - } - - return flag; - } - } - - public MissionIterator getIterator() { - mSelfMissionsControl = true; - return new MissionIterator(); - } - - /** - * Forget all finished downloads, but, doesn't delete any file - */ - public void forgetFinishedDownloads() { - synchronized (this) { - for (FinishedMission mission : mMissionsFinished) { - mFinishedMissionStore.deleteMission(mission); - } - mMissionsFinished.clear(); - } - } - - private boolean canDownloadInCurrentNetwork() { - if (mLastNetworkStatus == NetworkState.Unavailable) return false; - return !(mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating); - } - - void handleConnectivityState(NetworkState currentStatus, boolean updateOnly) { - if (currentStatus == mLastNetworkStatus) return; - - mLastNetworkStatus = currentStatus; - if (currentStatus == NetworkState.Unavailable) return; - - if (!mSelfMissionsControl || updateOnly) { - return;// don't touch anything without the user interaction - } - - boolean isMetered = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating; - - synchronized (this) { - for (DownloadMission mission : mMissionsPending) { - if (mission.isCorrupt() || mission.isPsRunning()) continue; - - if (mission.running && isMetered) { - mission.pause(); - } else if (!mission.running && !isMetered && mission.enqueued) { - mission.start(); - if (mPrefQueueLimit) break; - } - } - } - } - - void updateMaximumAttempts() { - synchronized (this) { - for (DownloadMission mission : mMissionsPending) mission.maxRetry = mPrefMaxRetry; - } - } - - public boolean canRecoverMission(DownloadMission mission) { - if (mission == null) return false; - - // Can recover missions with progress lost or storage issues - return mission.errCode == ERROR_PROGRESS_LOST || - mission.storage == null || - !mission.storage.existsAsFile(); - } - - public MissionState checkForExistingMission(StoredFileHelper storage) { - synchronized (this) { - DownloadMission pending = getPendingMission(storage); - - if (pending == null) { - if (getFinishedMissionIndex(storage) >= 0) return MissionState.Finished; - } else { - if (pending.isFinished()) { - return MissionState.Finished;// this never should happen (race-condition) - } else { - return pending.running ? MissionState.PendingRunning : MissionState.Pending; - } - } - } - - return MissionState.None; - } - - private static boolean isDirectoryAvailable(File directory) { - return directory != null && directory.canWrite() && directory.exists(); - } - - static File pickAvailableTemporalDir(@NonNull Context ctx) { - File dir = ctx.getExternalFilesDir(null); - if (isDirectoryAvailable(dir)) return dir; - - dir = ctx.getFilesDir(); - if (isDirectoryAvailable(dir)) return dir; - - // this never should happen - dir = ctx.getDir("muxing_tmp", Context.MODE_PRIVATE); - if (isDirectoryAvailable(dir)) return dir; - - // fallback to cache dir - dir = ctx.getCacheDir(); - if (isDirectoryAvailable(dir)) return dir; - - throw new RuntimeException("Not temporal directories are available"); - } - - @Nullable - private StoredDirectoryHelper getMainStorage(@NonNull String tag) { - if (tag.equals(TAG_AUDIO)) return mMainStorageAudio; - if (tag.equals(TAG_VIDEO)) return mMainStorageVideo; - - Log.w(TAG, "Unknown download category, not [audio video]: " + tag); - - return null;// this never should happen - } - - public class MissionIterator extends DiffUtil.Callback { - final Object FINISHED = new Object(); - final Object PENDING = new Object(); - - ArrayList snapshot; - ArrayList current; - ArrayList hidden; - - boolean hasFinished = false; - - private MissionIterator() { - hidden = new ArrayList<>(2); - current = null; - snapshot = getSpecialItems(); - } - - private ArrayList getSpecialItems() { - synchronized (DownloadManager.this) { - ArrayList pending = new ArrayList<>(mMissionsPending); - ArrayList finished = new ArrayList<>(mMissionsFinished); - List remove = new ArrayList<>(hidden); - - // Don't hide recoverable missions - remove.removeIf(mission -> { - if (mission instanceof DownloadMission dm && canRecoverMission(dm)) { - return false; // Don't remove recoverable missions - } - return pending.remove(mission) || finished.remove(mission); - }); - - int fakeTotal = pending.size(); - if (fakeTotal > 0) fakeTotal++; - - fakeTotal += finished.size(); - if (finished.size() > 0) fakeTotal++; - - ArrayList list = new ArrayList<>(fakeTotal); - if (pending.size() > 0) { - list.add(PENDING); - list.addAll(pending); - } - if (finished.size() > 0) { - list.add(FINISHED); - list.addAll(finished); - } - - hasFinished = finished.size() > 0; - - return list; - } - } - - public MissionItem getItem(int position) { - Object object = snapshot.get(position); - - if (object == PENDING) return new MissionItem(SPECIAL_PENDING); - if (object == FINISHED) return new MissionItem(SPECIAL_FINISHED); - - return new MissionItem(SPECIAL_NOTHING, (Mission) object); - } - - public int getSpecialAtItem(int position) { - Object object = snapshot.get(position); - - if (object == PENDING) return SPECIAL_PENDING; - if (object == FINISHED) return SPECIAL_FINISHED; - - return SPECIAL_NOTHING; - } - - - public void start() { - current = getSpecialItems(); - } - - public void end() { - snapshot = current; - current = null; - } - - public void hide(Mission mission) { - hidden.add(mission); - } - - public void unHide(Mission mission) { - hidden.remove(mission); - } - - public boolean hasFinishedMissions() { - return hasFinished; - } - - /** - * Check if exists missions running and paused. Corrupted and hidden missions are not counted - * - * @return two-dimensional array contains the current missions state. - * 1° entry: true if has at least one mission running - * 2° entry: true if has at least one mission paused - */ - public boolean[] hasValidPendingMissions() { - boolean running = false; - boolean paused = false; - - synchronized (DownloadManager.this) { - for (DownloadMission mission : mMissionsPending) { - if (hidden.contains(mission) || mission.isCorrupt()) - continue; - - if (mission.running) - running = true; - else - paused = true; - } - } - - return new boolean[]{running, paused}; - } - - - @Override - public int getOldListSize() { - return snapshot.size(); - } - - @Override - public int getNewListSize() { - return current.size(); - } - - @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { - return snapshot.get(oldItemPosition) == current.get(newItemPosition); - } - - @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - Object x = snapshot.get(oldItemPosition); - Object y = current.get(newItemPosition); - - if (x instanceof Mission && y instanceof Mission) { - return ((Mission) x).storage.equals(((Mission) y).storage); - } - - return false; - } - } - - public static class MissionItem { - public int special; - public Mission mission; - - MissionItem(int s, Mission m) { - special = s; - mission = m; - } - - MissionItem(int s) { - this(s, null); - } - } - -} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java deleted file mode 100755 index 76da18b2d..000000000 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ /dev/null @@ -1,589 +0,0 @@ -package us.shandian.giga.service; - -import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; -import static org.schabi.newpipe.BuildConfig.DEBUG; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.SharedPreferences.OnSharedPreferenceChangeListener; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkInfo; -import android.net.NetworkRequest; -import android.net.Uri; -import android.os.Binder; -import android.os.Handler; -import android.os.Handler.Callback; -import android.os.IBinder; -import android.os.Message; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.collection.SparseArrayCompat; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.PendingIntentCompat; -import androidx.core.app.ServiceCompat; -import androidx.core.content.ContextCompat; -import androidx.core.content.IntentCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.download.DownloadActivity; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.helper.LockManager; -import org.schabi.newpipe.streams.io.StoredDirectoryHelper; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Localization; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.postprocessing.Postprocessing; -import us.shandian.giga.service.DownloadManager.NetworkState; - -public class DownloadManagerService extends Service { - - private static final String TAG = "DownloadManagerService"; - - public static final int MESSAGE_RUNNING = 0; - public static final int MESSAGE_PAUSED = 1; - public static final int MESSAGE_FINISHED = 2; - public static final int MESSAGE_ERROR = 3; - public static final int MESSAGE_DELETED = 4; - - private static final int FOREGROUND_NOTIFICATION_ID = 1000; - private static final int DOWNLOADS_NOTIFICATION_ID = 1001; - - private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; - private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; - private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; - private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; - private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; - private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; - private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; - private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; - private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; - private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; - private static final String EXTRA_STREAM_INFO = "DownloadManagerService.extra.streamInfo"; - - private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; - private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; - - private DownloadManagerBinder mBinder; - private DownloadManager mManager; - private Notification mNotification; - private Handler mHandler; - private boolean mForeground = false; - private NotificationManager mNotificationManager = null; - private boolean mDownloadNotificationEnable = true; - - private int downloadDoneCount = 0; - private Builder downloadDoneNotification = null; - private StringBuilder downloadDoneList = null; - - private final List mEchoObservers = new ArrayList<>(1); - - private ConnectivityManager mConnectivityManager; - private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; - - private SharedPreferences mPrefs = null; - private final OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; - - private boolean mLockAcquired = false; - private LockManager mLock = null; - - private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; - private Builder downloadFailedNotification = null; - private final SparseArrayCompat mFailedDownloads = - new SparseArrayCompat<>(5); - - private Bitmap icLauncher; - private Bitmap icDownloadDone; - private Bitmap icDownloadFailed; - - private PendingIntent mOpenDownloadList; - - /** - * notify media scanner on downloaded media file ... - * - * @param file the downloaded file uri - */ - private void notifyMediaScanner(Uri file) { - sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); - } - - @Override - public void onCreate() { - super.onCreate(); - - if (DEBUG) { - Log.d(TAG, "onCreate"); - } - - mBinder = new DownloadManagerBinder(); - mHandler = new Handler(this::handleMessage); - - mPrefs = PreferenceManager.getDefaultSharedPreferences(this); - - mManager = new DownloadManager(this, mHandler, loadMainVideoStorage(), loadMainAudioStorage()); - - Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) - .setAction(Intent.ACTION_MAIN); - - mOpenDownloadList = PendingIntentCompat.getActivity(this, 0, - openDownloadListIntent, - PendingIntent.FLAG_UPDATE_CURRENT, false); - - icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); - - Builder builder = new Builder(this, getString(R.string.notification_channel_id)) - .setContentIntent(mOpenDownloadList) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setLargeIcon(icLauncher) - .setContentTitle(getString(R.string.msg_running)) - .setContentText(getString(R.string.msg_running_detail)); - - mNotification = builder.build(); - - mNotificationManager = ContextCompat.getSystemService(this, - NotificationManager.class); - mConnectivityManager = ContextCompat.getSystemService(this, - ConnectivityManager.class); - - mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(Network network) { - handleConnectivityState(false); - } - - @Override - public void onLost(Network network) { - handleConnectivityState(false); - } - }; - mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); - - mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); - - handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); - handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); - handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); - - mLock = new LockManager(this); - } - - @Override - public int onStartCommand(final Intent intent, int flags, int startId) { - if (DEBUG) { - Log.d(TAG, intent == null ? "Restarting" : "Starting"); - } - - if (intent == null) return START_NOT_STICKY; - - Log.i(TAG, "Got intent: " + intent); - String action = intent.getAction(); - if (action != null) { - if (action.equals(Intent.ACTION_RUN)) { - mHandler.post(() -> startMission(intent)); - } else if (downloadDoneNotification != null) { - if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { - downloadDoneCount = 0; - downloadDoneList.setLength(0); - } - if (action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { - startActivity(new Intent(this, DownloadActivity.class) - .setAction(Intent.ACTION_MAIN) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ); - } - return START_NOT_STICKY; - } - } - - return START_STICKY; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (DEBUG) { - Log.d(TAG, "Destroying"); - } - - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - - if (mNotificationManager != null && downloadDoneNotification != null) { - downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc - mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); - } - - manageLock(false); - - mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); - - mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); - - if (icDownloadDone != null) icDownloadDone.recycle(); - if (icDownloadFailed != null) icDownloadFailed.recycle(); - if (icLauncher != null) icLauncher.recycle(); - - mHandler = null; - mManager.pauseAllMissions(true); - } - - @Override - public IBinder onBind(Intent intent) { - return mBinder; - } - - private boolean handleMessage(@NonNull Message msg) { - if (mHandler == null) return true; - - DownloadMission mission = (DownloadMission) msg.obj; - - switch (msg.what) { - case MESSAGE_FINISHED: - notifyMediaScanner(mission.storage.getUri()); - notifyFinishedDownload(mission.storage.getName()); - mManager.setFinished(mission); - handleConnectivityState(false); - updateForegroundState(mManager.runMissions()); - break; - case MESSAGE_RUNNING: - updateForegroundState(true); - break; - case MESSAGE_ERROR: - notifyFailedDownload(mission); - handleConnectivityState(false); - updateForegroundState(mManager.runMissions()); - break; - case MESSAGE_PAUSED: - updateForegroundState(mManager.getRunningMissionsCount() > 0); - break; - } - - if (msg.what != MESSAGE_ERROR) - mFailedDownloads.remove(mFailedDownloads.indexOfValue(mission)); - - for (Callback observer : mEchoObservers) - observer.handleMessage(msg); - - return true; - } - - private void handleConnectivityState(boolean updateOnly) { - NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); - NetworkState status; - - if (info == null) { - status = NetworkState.Unavailable; - Log.i(TAG, "Active network [connectivity is unavailable]"); - } else { - boolean connected = info.isConnected(); - boolean metered = mConnectivityManager.isActiveNetworkMetered(); - - if (connected) - status = metered ? NetworkState.MeteredOperating : NetworkState.Operating; - else - status = NetworkState.Unavailable; - - Log.i(TAG, "Active network [connected=" + connected + " metered=" + metered + "] " + info.toString()); - } - - if (mManager == null) return;// avoid race-conditions while the service is starting - mManager.handleConnectivityState(status, updateOnly); - } - - private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) { - if (getString(R.string.downloads_maximum_retry).equals(key)) { - try { - String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); - mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); - } catch (Exception e) { - mManager.mPrefMaxRetry = 0; - } - mManager.updateMaximumAttempts(); - } else if (getString(R.string.downloads_cross_network).equals(key)) { - mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); - } else if (getString(R.string.downloads_queue_limit).equals(key)) { - mManager.mPrefQueueLimit = prefs.getBoolean(key, true); - } else if (getString(R.string.download_path_video_key).equals(key)) { - mManager.mMainStorageVideo = loadMainVideoStorage(); - } else if (getString(R.string.download_path_audio_key).equals(key)) { - mManager.mMainStorageAudio = loadMainAudioStorage(); - } - } - - public void updateForegroundState(boolean state) { - if (state == mForeground) return; - - if (state) { - startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); - } else { - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - } - - manageLock(state); - - mForeground = state; - } - - /** - * Start a new download mission - * - * @param context the activity context - * @param urls array of urls to download - * @param storage where the file is saved - * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) - * @param threads the number of threads maximal used to download chunks of the file. - * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. - * @param streamInfo stream metadata that may be written into the downloaded file. - * @param psArgs the arguments for the post-processing algorithm. - * @param nearLength the approximated final length of the file - * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download - */ - public static void startMission(Context context, String[] urls, StoredFileHelper storage, - char kind, int threads, StreamInfo streamInfo, String psName, - String[] psArgs, long nearLength, - ArrayList recoveryInfo) { - final Intent intent = new Intent(context, DownloadManagerService.class) - .setAction(Intent.ACTION_RUN) - .putExtra(EXTRA_URLS, urls) - .putExtra(EXTRA_KIND, kind) - .putExtra(EXTRA_THREADS, threads) - .putExtra(EXTRA_POSTPROCESSING_NAME, psName) - .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) - .putExtra(EXTRA_NEAR_LENGTH, nearLength) - .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) - .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) - .putExtra(EXTRA_PATH, storage.getUri()) - .putExtra(EXTRA_STORAGE_TAG, storage.getTag()) - .putExtra(EXTRA_STREAM_INFO, streamInfo); - - context.startService(intent); - } - - private void startMission(Intent intent) { - String[] urls = intent.getStringArrayExtra(EXTRA_URLS); - Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class); - Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class); - int threads = intent.getIntExtra(EXTRA_THREADS, 1); - char kind = intent.getCharExtra(EXTRA_KIND, '?'); - String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); - String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); - long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); - String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); - StreamInfo streamInfo = (StreamInfo)intent.getSerializableExtra(EXTRA_STREAM_INFO); - final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, - MissionRecoveryInfo.class); - Objects.requireNonNull(recovery); - - StoredFileHelper storage; - try { - storage = new StoredFileHelper(this, parentPath, path, tag); - } catch (IOException e) { - throw new RuntimeException(e);// this never should happen - } - - Postprocessing ps; - if (psName == null) - ps = null; - else - ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); - - final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); - mission.threadCount = threads; - mission.source = streamInfo.getUrl(); - mission.nearLength = nearLength; - mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); - - if (ps != null) - ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); - - handleConnectivityState(true);// first check the actual network status - - mManager.startMission(mission); - } - - public void notifyFinishedDownload(String name) { - if (!mDownloadNotificationEnable || mNotificationManager == null) { - return; - } - - if (downloadDoneNotification == null) { - downloadDoneList = new StringBuilder(name.length()); - - icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); - downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) - .setAutoCancel(true) - .setLargeIcon(icDownloadDone) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setDeleteIntent(makePendingIntent(ACTION_RESET_DOWNLOAD_FINISHED)) - .setContentIntent(makePendingIntent(ACTION_OPEN_DOWNLOADS_FINISHED)); - } - - downloadDoneCount++; - if (downloadDoneCount == 1) { - downloadDoneList.append(name); - - downloadDoneNotification.setContentTitle(null); - downloadDoneNotification.setContentText(Localization.downloadCount(this, downloadDoneCount)); - downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() - .setBigContentTitle(Localization.downloadCount(this, downloadDoneCount)) - .bigText(name) - ); - } else { - downloadDoneList.append('\n'); - downloadDoneList.append(name); - - downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle().bigText(downloadDoneList)); - downloadDoneNotification.setContentTitle(Localization.downloadCount(this, downloadDoneCount)); - downloadDoneNotification.setContentText(downloadDoneList); - } - - mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); - } - - public void notifyFailedDownload(DownloadMission mission) { - if (!mDownloadNotificationEnable || mFailedDownloads.containsValue(mission)) return; - - int id = downloadFailedNotificationID++; - mFailedDownloads.put(id, mission); - - if (downloadFailedNotification == null) { - icDownloadFailed = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_warning); - downloadFailedNotification = new Builder(this, getString(R.string.notification_channel_id)) - .setAutoCancel(true) - .setLargeIcon(icDownloadFailed) - .setSmallIcon(android.R.drawable.stat_sys_warning) - .setContentIntent(mOpenDownloadList); - } - - downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); - downloadFailedNotification.setContentText(mission.storage.getName()); - downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mission.storage.getName())); - - mNotificationManager.notify(id, downloadFailedNotification.build()); - } - - private PendingIntent makePendingIntent(String action) { - Intent intent = new Intent(this, DownloadManagerService.class).setAction(action); - return PendingIntentCompat.getService(this, intent.hashCode(), intent, - PendingIntent.FLAG_UPDATE_CURRENT, false); - } - - private void manageLock(boolean acquire) { - if (acquire == mLockAcquired) return; - - if (acquire) - mLock.acquireWifiAndCpu(); - else - mLock.releaseWifiAndCpu(); - - mLockAcquired = acquire; - } - - private StoredDirectoryHelper loadMainVideoStorage() { - return loadMainStorage(R.string.download_path_video_key, DownloadManager.TAG_VIDEO); - } - - private StoredDirectoryHelper loadMainAudioStorage() { - return loadMainStorage(R.string.download_path_audio_key, DownloadManager.TAG_AUDIO); - } - - private StoredDirectoryHelper loadMainStorage(@StringRes int prefKey, String tag) { - String path = mPrefs.getString(getString(prefKey), null); - - if (path == null || path.isEmpty()) return null; - - if (path.charAt(0) == File.separatorChar) { - Log.i(TAG, "Old save path style present: " + path); - path = ""; - mPrefs.edit().putString(getString(prefKey), "").apply(); - } - - try { - return new StoredDirectoryHelper(this, Uri.parse(path), tag); - } catch (Exception e) { - Log.e(TAG, "Failed to load the storage of " + tag + " from " + path, e); - Toast.makeText(this, R.string.no_available_dir, Toast.LENGTH_LONG).show(); - } - - return null; - } - - //////////////////////////////////////////////////////////////////////////////////////////////// - // Wrappers for DownloadManager - //////////////////////////////////////////////////////////////////////////////////////////////// - - public class DownloadManagerBinder extends Binder { - public DownloadManager getDownloadManager() { - return mManager; - } - - @Nullable - public StoredDirectoryHelper getMainStorageVideo() { - return mManager.mMainStorageVideo; - } - - @Nullable - public StoredDirectoryHelper getMainStorageAudio() { - return mManager.mMainStorageAudio; - } - - public boolean askForSavePath() { - return DownloadManagerService.this.mPrefs.getBoolean( - DownloadManagerService.this.getString(R.string.downloads_storage_ask), - false - ); - } - - public void addMissionEventListener(Callback handler) { - mEchoObservers.add(handler); - } - - public void removeMissionEventListener(Callback handler) { - mEchoObservers.remove(handler); - } - - public void clearDownloadNotifications() { - if (mNotificationManager == null) return; - if (downloadDoneNotification != null) { - mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); - downloadDoneList.setLength(0); - downloadDoneCount = 0; - } - if (downloadFailedNotification != null) { - for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { - mNotificationManager.cancel(downloadFailedNotificationID); - } - mFailedDownloads.clear(); - downloadFailedNotificationID++; - } - } - - public void enableNotifications(boolean enable) { - mDownloadNotificationEnable = enable; - } - - } - -} diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.java b/app/src/main/java/us/shandian/giga/service/MissionState.java deleted file mode 100644 index 2d7802ff5..000000000 --- a/app/src/main/java/us/shandian/giga/service/MissionState.java +++ /dev/null @@ -1,5 +0,0 @@ -package us.shandian.giga.service; - -public enum MissionState { - None, Pending, PendingRunning, Finished -} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java deleted file mode 100644 index 0ea3b1ac3..000000000 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ /dev/null @@ -1,1000 +0,0 @@ -package us.shandian.giga.ui.adapter; - -import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; -import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; -import static android.content.Intent.createChooser; -import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; -import static us.shandian.giga.get.DownloadMission.ERROR_FILE_CREATION; -import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_NO_CONTENT; -import static us.shandian.giga.get.DownloadMission.ERROR_INSUFFICIENT_STORAGE; -import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; -import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; -import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; -import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; -import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; -import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; -import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; -import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; -import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; -import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; -import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; -import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; - -import android.annotation.SuppressLint; -import android.app.NotificationManager; -import android.content.Context; -import android.content.Intent; -import android.graphics.Color; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.Message; -import android.util.Log; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.MimeTypeMap; -import android.widget.ImageView; -import android.widget.PopupMenu; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.core.app.NotificationCompat; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.os.HandlerCompat; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.Adapter; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -import com.google.android.material.snackbar.Snackbar; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.io.File; -import java.net.URI; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.Date; -import java.util.Locale; -import java.text.DateFormat; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; -import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.service.DownloadManager; -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.ui.common.Deleter; -import us.shandian.giga.ui.common.ProgressDrawable; -import us.shandian.giga.util.Utility; - -public class MissionAdapter extends Adapter implements Handler.Callback { - private static final String TAG = "MissionAdapter"; - private static final String UNDEFINED_PROGRESS = "--.-%"; - private static final String DEFAULT_MIME_TYPE = "*/*"; - private static final String UNDEFINED_ETA = "--:--"; - - private static final String UPDATER = "updater"; - private static final String DELETE = "deleteFinishedDownloads"; - - private static final int HASH_NOTIFICATION_ID = 123790; - - private final Context mContext; - private final LayoutInflater mInflater; - private final DownloadManager mDownloadManager; - private final Deleter mDeleter; - private int mLayout; - private final DownloadManager.MissionIterator mIterator; - private final ArrayList mPendingDownloadsItems = new ArrayList<>(); - private final Handler mHandler; - private MenuItem mClear; - private MenuItem mStartButton; - private MenuItem mPauseButton; - private final View mEmptyMessage; - private RecoverHelper mRecover; - private final View mView; - private final ArrayList mHidden; - private Snackbar mSnackbar; - - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { - mContext = context; - mDownloadManager = downloadManager; - - mInflater = LayoutInflater.from(mContext); - mLayout = R.layout.mission_item; - - mHandler = new Handler(context.getMainLooper()); - - mEmptyMessage = emptyMessage; - - mIterator = downloadManager.getIterator(); - - mDeleter = new Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler); - - mView = root; - - mHidden = new ArrayList<>(); - - checkEmptyMessageVisibility(); - onResume(); - } - - @Override - @NonNull - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - case DownloadManager.SPECIAL_PENDING: - case DownloadManager.SPECIAL_FINISHED: - return new ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)); - } - - return new ViewHolderItem(mInflater.inflate(mLayout, parent, false)); - } - - @Override - public void onViewRecycled(@NonNull ViewHolder view) { - super.onViewRecycled(view); - - if (view instanceof ViewHolderHeader) return; - ViewHolderItem h = (ViewHolderItem) view; - - if (h.item.mission instanceof DownloadMission) { - mPendingDownloadsItems.remove(h); - if (mPendingDownloadsItems.size() < 1) { - checkMasterButtonsVisibility(); - } - } - - h.popupMenu.dismiss(); - h.item = null; - h.resetSpeedMeasure(); - } - - @Override - @SuppressLint("SetTextI18n") - public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { - DownloadManager.MissionItem item = mIterator.getItem(pos); - - if (view instanceof ViewHolderHeader) { - if (item.special == DownloadManager.SPECIAL_NOTHING) return; - int str; - if (item.special == DownloadManager.SPECIAL_PENDING) { - str = R.string.missions_header_pending; - } else { - str = R.string.missions_header_finished; - if (mClear != null) mClear.setVisible(true); - } - - ((ViewHolderHeader) view).header.setText(str); - return; - } - - ViewHolderItem h = (ViewHolderItem) view; - h.item = item; - - Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); - - h.icon.setImageResource(Utility.getIconForFileType(type)); - h.name.setText(item.mission.storage.getName()); - - h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); - - if (h.item.mission instanceof DownloadMission) { - DownloadMission mission = (DownloadMission) item.mission; - String length = Utility.formatBytes(mission.getLength()); - if (mission.running && !mission.isPsRunning()) length += " --.- kB/s"; - - h.size.setText(length); - h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); - updateProgress(h); - mPendingDownloadsItems.add(h); - - h.date.setText(""); - } else { - h.progress.setMarquee(false); - h.status.setText("100%"); - h.progress.setProgress(1.0f); - h.size.setText(Utility.formatBytes(item.mission.length)); - - DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()); - Date date = new Date(item.mission.timestamp); - h.date.setText(dateFormat.format(date)); - } - } - - @Override - public int getItemCount() { - return mIterator.getOldListSize(); - } - - @Override - public int getItemViewType(int position) { - return mIterator.getSpecialAtItem(position); - } - - @SuppressLint("DefaultLocale") - private void updateProgress(ViewHolderItem h) { - if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; - - DownloadMission mission = (DownloadMission) h.item.mission; - double done = mission.done; - long length = mission.getLength(); - long now = System.currentTimeMillis(); - boolean hasError = mission.errCode != ERROR_NOTHING; - - // hide on error - // show if current resource length is not fetched - // show if length is unknown - h.progress.setMarquee(mission.isRecovering() || !hasError && (!mission.isInitialized() || mission.unknownLength)); - - double progress; - if (mission.unknownLength) { - progress = Double.NaN; - h.progress.setProgress(0.0f); - } else { - progress = done / length; - } - - if (hasError) { - h.progress.setProgress(isNotFinite(progress) ? 1d : progress); - h.status.setText(R.string.msg_error); - } else if (isNotFinite(progress)) { - h.status.setText(UNDEFINED_PROGRESS); - } else { - h.status.setText(String.format("%.2f%%", progress * 100)); - h.progress.setProgress(progress); - } - - @StringRes int state; - String sizeStr = Utility.formatBytes(length).concat(" "); - - if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { - h.size.setText(sizeStr); - return; - } else if (!mission.running) { - state = mission.enqueued ? R.string.queued : R.string.paused; - } else if (mission.isPsRunning()) { - state = R.string.post_processing; - } else if (mission.isRecovering()) { - state = R.string.recovering; - } else { - state = 0; - } - - if (state != 0) { - // update state without download speed - h.size.setText(sizeStr.concat("(").concat(mContext.getString(state)).concat(")")); - h.resetSpeedMeasure(); - return; - } - - if (h.lastTimestamp < 0) { - h.size.setText(sizeStr); - h.lastTimestamp = now; - h.lastDone = done; - return; - } - - long deltaTime = now - h.lastTimestamp; - double deltaDone = done - h.lastDone; - - if (h.lastDone > done) { - h.lastDone = done; - h.size.setText(sizeStr); - return; - } - - if (deltaDone > 0 && deltaTime > 0) { - float speed = (float) ((deltaDone * 1000d) / deltaTime); - float averageSpeed = speed; - - if (h.lastSpeedIdx < 0) { - Arrays.fill(h.lastSpeed, speed); - h.lastSpeedIdx = 0; - } else { - for (int i = 0; i < h.lastSpeed.length; i++) { - averageSpeed += h.lastSpeed[i]; - } - averageSpeed /= h.lastSpeed.length + 1.0f; - } - - String speedStr = Utility.formatSpeed(averageSpeed); - String etaStr; - - if (mission.unknownLength) { - etaStr = ""; - } else { - long eta = (long) Math.ceil((length - done) / averageSpeed); - etaStr = Utility.formatBytes((long) done) + "/" + Utility.stringifySeconds(eta) + " "; - } - - h.size.setText(sizeStr.concat(etaStr).concat(speedStr)); - - h.lastTimestamp = now; - h.lastDone = done; - h.lastSpeed[h.lastSpeedIdx++] = speed; - - if (h.lastSpeedIdx >= h.lastSpeed.length) h.lastSpeedIdx = 0; - } - } - - private void viewWithFileProvider(Mission mission) { - if (checkInvalidFile(mission)) return; - - String mimeType = resolveMimeType(mission); - - if (BuildConfig.DEBUG) - Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - - Intent viewIntent = new Intent(Intent.ACTION_VIEW); - viewIntent.setDataAndType(resolveShareableUri(mission), mimeType); - viewIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - viewIntent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); - - Intent chooserIntent = createChooser(viewIntent, null); - chooserIntent.addFlags(FLAG_ACTIVITY_NEW_TASK); - chooserIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - chooserIntent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); - - ShareUtils.openIntentInApp(mContext, chooserIntent); - } - - private void shareFile(Mission mission) { - if (checkInvalidFile(mission)) return; - - final Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType(resolveMimeType(mission)); - shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); - shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - - final Intent intent = createChooser(shareIntent, null); - // unneeded to set a title to the chooser on Android P and higher because the system - // ignores this title on these versions - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { - intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); - } - intent.addFlags(FLAG_ACTIVITY_NEW_TASK); - intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - - mContext.startActivity(intent); - } - - /** - * Returns an Uri which can be shared to other applications. - * - * @see - * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed - */ - private Uri resolveShareableUri(Mission mission) { - if (mission.storage.isDirect()) { - return FileProvider.getUriForFile( - mContext, - BuildConfig.APPLICATION_ID + ".provider", - new File(URI.create(mission.storage.getUri().toString())) - ); - } else { - return mission.storage.getUri(); - } - } - - private static String resolveMimeType(@NonNull Mission mission) { - String mimeType; - - if (!mission.storage.isInvalid()) { - mimeType = mission.storage.getType(); - if (mimeType != null && mimeType.length() > 0 && !mimeType.equals(StoredFileHelper.DEFAULT_MIME)) - return mimeType; - } - - String ext = Utility.getFileExt(mission.storage.getName()); - if (ext == null) return DEFAULT_MIME_TYPE; - - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); - - return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; - } - - private boolean checkInvalidFile(@NonNull Mission mission) { - if (mission.storage.existsAsFile()) return false; - - Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); - return true; - } - - private ViewHolderItem getViewHolder(Object mission) { - for (ViewHolderItem h : mPendingDownloadsItems) { - if (h.item.mission == mission) return h; - } - return null; - } - - @Override - public boolean handleMessage(@NonNull Message msg) { - if (mStartButton != null && mPauseButton != null) { - checkMasterButtonsVisibility(); - } - - switch (msg.what) { - case DownloadManagerService.MESSAGE_ERROR: - case DownloadManagerService.MESSAGE_FINISHED: - case DownloadManagerService.MESSAGE_DELETED: - case DownloadManagerService.MESSAGE_PAUSED: - break; - default: - return false; - } - - ViewHolderItem h = getViewHolder(msg.obj); - if (h == null) return false; - - switch (msg.what) { - case DownloadManagerService.MESSAGE_FINISHED: - case DownloadManagerService.MESSAGE_DELETED: - // DownloadManager should mark the download as finished - applyChanges(); - return true; - } - - updateProgress(h); - return true; - } - - private void showError(@NonNull DownloadMission mission) { - @StringRes int msg = R.string.general_error; - String msgEx = null; - - switch (mission.errCode) { - case 416: - msg = R.string.error_http_unsupported_range; - break; - case 404: - msg = R.string.error_http_not_found; - break; - case ERROR_NOTHING: - return;// this never should happen - case ERROR_FILE_CREATION: - msg = R.string.error_file_creation; - break; - case ERROR_HTTP_NO_CONTENT: - msg = R.string.error_http_no_content; - break; - case ERROR_PATH_CREATION: - msg = R.string.error_path_creation; - break; - case ERROR_PERMISSION_DENIED: - msg = R.string.permission_denied; - break; - case ERROR_SSL_EXCEPTION: - msg = R.string.error_ssl_exception; - break; - case ERROR_UNKNOWN_HOST: - msg = R.string.error_unknown_host; - break; - case ERROR_CONNECT_HOST: - msg = R.string.error_connect_host; - break; - case ERROR_POSTPROCESSING_STOPPED: - msg = R.string.error_postprocessing_stopped; - break; - case ERROR_POSTPROCESSING: - case ERROR_POSTPROCESSING_HOLD: - showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); - return; - case ERROR_INSUFFICIENT_STORAGE: - msg = R.string.error_insufficient_storage_left; - break; - case ERROR_UNKNOWN_EXCEPTION: - if (mission.errObject != null) { - showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error); - return; - } else { - msg = R.string.msg_error; - break; - } - case ERROR_PROGRESS_LOST: - msg = R.string.error_progress_lost; - break; - case ERROR_TIMEOUT: - msg = R.string.error_timeout; - break; - case ERROR_RESOURCE_GONE: - msg = R.string.error_download_resource_gone; - break; - default: - if (mission.errCode >= 100 && mission.errCode < 600) { - msgEx = "HTTP " + mission.errCode; - } else if (mission.errObject == null) { - msgEx = "(not_decelerated_error_code)"; - } else { - showError(mission, UserAction.DOWNLOAD_FAILED, msg); - return; - } - break; - } - - AlertDialog.Builder builder = new AlertDialog.Builder(mContext); - - if (msgEx != null) - builder.setMessage(msgEx); - else - builder.setMessage(msg); - - // add report button for non-HTTP errors (range 100-599) - if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { - @StringRes final int mMsg = msg; - builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> - showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) - ); - } - - builder.setNegativeButton(R.string.ok, (dialog, which) -> dialog.cancel()) - .setTitle(mission.storage.getName()) - .show(); - } - - private void showError(DownloadMission mission, UserAction action, @StringRes int reason) { - StringBuilder request = new StringBuilder(256); - request.append(mission.source); - - request.append(" ["); - if (mission.recoveryInfo != null) { - for (MissionRecoveryInfo recovery : mission.recoveryInfo) - request.append(' ') - .append(recovery.toString()) - .append(' '); - } - request.append("]"); - - Integer service; - try { - service = NewPipe.getServiceByUrl(mission.source).getServiceId(); - } catch (Exception e) { - service = null; - } - - ErrorUtil.createNotification(mContext, - new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action, - request.toString(), service, reason)); - } - - public void clearFinishedDownloads(boolean delete) { - if (delete && mIterator.hasFinishedMissions() && mHidden.isEmpty()) { - for (int i = 0; i < mIterator.getOldListSize(); i++) { - FinishedMission mission = mIterator.getItem(i).mission instanceof FinishedMission ? (FinishedMission) mIterator.getItem(i).mission : null; - if (mission != null) { - mIterator.hide(mission); - mHidden.add(mission); - } - } - applyChanges(); - - String msg = Localization.deletedDownloadCount(mContext, mHidden.size()); - mSnackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); - mSnackbar.setAction(R.string.undo, s -> { - Iterator i = mHidden.iterator(); - while (i.hasNext()) { - mIterator.unHide(i.next()); - i.remove(); - } - applyChanges(); - mHandler.removeCallbacksAndMessages(DELETE); - }); - mSnackbar.setActionTextColor(Color.YELLOW); - mSnackbar.show(); - - HandlerCompat.postDelayed(mHandler, this::deleteFinishedDownloads, DELETE, 5000); - } else if (!delete) { - mDownloadManager.forgetFinishedDownloads(); - applyChanges(); - } - } - - private void deleteFinishedDownloads() { - if (mSnackbar != null) mSnackbar.dismiss(); - - Iterator i = mHidden.iterator(); - while (i.hasNext()) { - Mission mission = i.next(); - if (mission != null) { - mDownloadManager.deleteMission(mission, true); - mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); - } - i.remove(); - } - } - - private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { - if (h.item == null) return true; - - int id = option.getItemId(); - DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; - - if (mission != null) { - if (id == R.id.start) { - h.status.setText(UNDEFINED_PROGRESS); - mDownloadManager.resumeMission(mission); - return true; - } else if (id == R.id.pause) { - mDownloadManager.pauseMission(mission); - return true; - } else if (id == R.id.error_message_view) { - showError(mission); - return true; - } else if (id == R.id.queue) { - boolean flag = !h.queue.isChecked(); - h.queue.setChecked(flag); - mission.setEnqueued(flag); - updateProgress(h); - return true; - } else if (id == R.id.retry) { - if (mission.isPsRunning()) { - mission.psContinue(true); - } else { - mDownloadManager.tryRecover(mission); - if (mission.storage.isInvalid()) - mRecover.tryRecover(mission); - else - recoverMission(mission); - } - return true; - } else if (id == R.id.cancel) { - mission.psContinue(false); - return false; - } - } - - if (id == R.id.menu_item_share) { - shareFile(h.item.mission); - return true; - } else if (id == R.id.delete) {// delete the entry and the file - mDeleter.append(h.item.mission, true); - applyChanges(); - checkMasterButtonsVisibility(); - return true; - } else if (id == R.id.delete_entry) {// just delete the entry - mDeleter.append(h.item.mission, false); - applyChanges(); - checkMasterButtonsVisibility(); - return true; - } else if (id == R.id.md5 || id == R.id.sha1) { - final StoredFileHelper storage = h.item.mission.storage; - if (!storage.existsAsFile()) { - Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); - mDeleter.append(h.item.mission, true); - applyChanges(); - return true; - } - final NotificationManager notificationManager - = ContextCompat.getSystemService(mContext, NotificationManager.class); - final NotificationCompat.Builder progressNotificationBuilder - = new NotificationCompat.Builder(mContext, - mContext.getString(R.string.hash_channel_id)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setContentTitle(mContext.getString(R.string.msg_calculating_hash)) - .setContentText(mContext.getString(R.string.msg_wait)) - .setProgress(0, 0, true) - .setOngoing(true); - - notificationManager.notify(HASH_NOTIFICATION_ID, progressNotificationBuilder - .build()); - compositeDisposable.add( - Observable.fromCallable(() -> Utility.checksum(storage, id)) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - ShareUtils.copyToClipboard(mContext, result); - notificationManager.cancel(HASH_NOTIFICATION_ID); - }) - ); - return true; - } else if (id == R.id.source) { - try { - Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); - intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); - mContext.startActivity(intent); - } catch (Exception e) { - Log.w(TAG, "Selected item has a invalid source", e); - } - return true; - } - return false; - } - - public void applyChanges() { - mIterator.start(); - DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); - mIterator.end(); - - checkEmptyMessageVisibility(); - if (mClear != null) mClear.setVisible(mIterator.hasFinishedMissions()); - } - - public void forceUpdate() { - mIterator.start(); - mIterator.end(); - - for (ViewHolderItem item : mPendingDownloadsItems) { - item.resetSpeedMeasure(); - } - - notifyDataSetChanged(); - } - - public void setLinear(boolean isLinear) { - mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; - } - - public void setClearButton(MenuItem clearButton) { - if (mClear == null) - clearButton.setVisible(mIterator.hasFinishedMissions()); - - mClear = clearButton; - } - - public void setMasterButtons(MenuItem startButton, MenuItem pauseButton) { - boolean init = mStartButton == null || mPauseButton == null; - - mStartButton = startButton; - mPauseButton = pauseButton; - - if (init) checkMasterButtonsVisibility(); - } - - private void checkEmptyMessageVisibility() { - int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; - if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); - } - - public void checkMasterButtonsVisibility() { - boolean[] state = mIterator.hasValidPendingMissions(); - Log.d(TAG, "checkMasterButtonsVisibility() running=" + state[0] + " paused=" + state[1]); - setButtonVisible(mPauseButton, state[0]); - setButtonVisible(mStartButton, state[1]); - } - - private static void setButtonVisible(MenuItem button, boolean visible) { - if (button.isVisible() != visible) - button.setVisible(visible); - } - - public void refreshMissionItems() { - for (ViewHolderItem h : mPendingDownloadsItems) { - if (((DownloadMission) h.item.mission).running) continue; - updateProgress(h); - h.resetSpeedMeasure(); - } - } - - public void onDestroy() { - compositeDisposable.dispose(); - mDeleter.dispose(); - } - - public void onResume() { - mDeleter.resume(); - HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 0); - } - - public void onPaused() { - mDeleter.pause(); - mHandler.removeCallbacksAndMessages(UPDATER); - } - - public void recoverMission(DownloadMission mission) { - ViewHolderItem h = getViewHolder(mission); - if (h == null) return; - - mission.errObject = null; - mission.resetState(true, false, DownloadMission.ERROR_NOTHING); - - h.status.setText(UNDEFINED_PROGRESS); - h.size.setText(Utility.formatBytes(mission.getLength())); - h.progress.setMarquee(true); - - mDownloadManager.resumeMission(mission); - } - - private void updater() { - for (ViewHolderItem h : mPendingDownloadsItems) { - // check if the mission is running first - if (!((DownloadMission) h.item.mission).running) continue; - - updateProgress(h); - } - - HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 1000); - } - - private boolean isNotFinite(double value) { - return Double.isNaN(value) || Double.isInfinite(value); - } - - public void setRecover(@NonNull RecoverHelper callback) { - mRecover = callback; - } - - - class ViewHolderItem extends RecyclerView.ViewHolder { - DownloadManager.MissionItem item; - - TextView status; - ImageView icon; - TextView name; - TextView size; - TextView date; - ProgressDrawable progress; - - PopupMenu popupMenu; - MenuItem retry; - MenuItem cancel; - MenuItem start; - MenuItem pause; - MenuItem open; - MenuItem queue; - MenuItem showError; - MenuItem delete; - MenuItem source; - MenuItem checksum; - - long lastTimestamp = -1; - double lastDone; - int lastSpeedIdx; - float[] lastSpeed = new float[3]; - String estimatedTimeArrival = UNDEFINED_ETA; - - ViewHolderItem(View view) { - super(view); - - progress = new ProgressDrawable(); - itemView.findViewById(R.id.item_bkg).setBackground(progress); - - status = itemView.findViewById(R.id.item_status); - name = itemView.findViewById(R.id.item_name); - icon = itemView.findViewById(R.id.item_icon); - size = itemView.findViewById(R.id.item_size); - date = itemView.findViewById(R.id.item_date); - - name.setSelected(true); - - ImageView button = itemView.findViewById(R.id.item_more); - popupMenu = buildPopup(button); - button.setOnClickListener(v -> showPopupMenu()); - - Menu menu = popupMenu.getMenu(); - retry = menu.findItem(R.id.retry); - cancel = menu.findItem(R.id.cancel); - start = menu.findItem(R.id.start); - pause = menu.findItem(R.id.pause); - open = menu.findItem(R.id.menu_item_share); - queue = menu.findItem(R.id.queue); - showError = menu.findItem(R.id.error_message_view); - delete = menu.findItem(R.id.delete); - source = menu.findItem(R.id.source); - checksum = menu.findItem(R.id.checksum); - - itemView.setHapticFeedbackEnabled(true); - - itemView.setOnClickListener(v -> { - if (item.mission instanceof FinishedMission) - viewWithFileProvider(item.mission); - }); - - itemView.setOnLongClickListener(v -> { - v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - showPopupMenu(); - return true; - }); - } - - private void showPopupMenu() { - retry.setVisible(false); - cancel.setVisible(false); - start.setVisible(false); - pause.setVisible(false); - open.setVisible(false); - queue.setVisible(false); - showError.setVisible(false); - delete.setVisible(false); - source.setVisible(false); - checksum.setVisible(false); - - DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; - - if (mission != null) { - if (mission.hasInvalidStorage()) { - retry.setVisible(true); - delete.setVisible(true); - showError.setVisible(true); - } else if (mission.isPsRunning()) { - switch (mission.errCode) { - case ERROR_INSUFFICIENT_STORAGE: - case ERROR_POSTPROCESSING_HOLD: - retry.setVisible(true); - cancel.setVisible(true); - showError.setVisible(true); - break; - } - } else { - if (mission.running) { - pause.setVisible(true); - } else { - if (mission.errCode != ERROR_NOTHING) { - showError.setVisible(true); - } - - queue.setChecked(mission.enqueued); - - delete.setVisible(true); - - boolean flag = !mission.isPsFailed() && mission.urls.length > 0; - start.setVisible(flag); - queue.setVisible(flag); - } - } - } else { - open.setVisible(true); - delete.setVisible(true); - checksum.setVisible(true); - } - - if (item.mission.source != null && !item.mission.source.isEmpty()) { - source.setVisible(true); - } - - popupMenu.show(); - } - - private PopupMenu buildPopup(final View button) { - PopupMenu popup = new PopupMenu(mContext, button); - popup.inflate(R.menu.mission); - popup.setOnMenuItemClickListener(option -> handlePopupItem(this, option)); - - return popup; - } - - private void resetSpeedMeasure() { - estimatedTimeArrival = UNDEFINED_ETA; - lastTimestamp = -1; - lastSpeedIdx = -1; - } - } - - static class ViewHolderHeader extends RecyclerView.ViewHolder { - TextView header; - - ViewHolderHeader(View view) { - super(view); - header = itemView.findViewById(R.id.item_name); - } - } - - public interface RecoverHelper { - void tryRecover(DownloadMission mission); - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java deleted file mode 100644 index 0f285fd74..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ /dev/null @@ -1,164 +0,0 @@ -package us.shandian.giga.ui.common; - -import android.content.Context; -import android.content.Intent; -import android.graphics.Color; -import android.os.Handler; -import android.view.View; - -import androidx.core.os.HandlerCompat; - -import com.google.android.material.snackbar.Snackbar; - -import org.schabi.newpipe.R; - -import java.util.ArrayList; -import java.util.Optional; - -import kotlin.Pair; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; -import us.shandian.giga.service.DownloadManager; -import us.shandian.giga.service.DownloadManager.MissionIterator; -import us.shandian.giga.ui.adapter.MissionAdapter; - -public class Deleter { - private static final String COMMIT = "commit"; - private static final String NEXT = "next"; - private static final String SHOW = "show"; - - private static final int TIMEOUT = 5000;// ms - private static final int DELAY = 350;// ms - private static final int DELAY_RESUME = 400;// ms - - private Snackbar snackbar; - // list of missions to be deleted, and whether to also delete the corresponding file - private ArrayList> items; - private boolean running = true; - - private final Context mContext; - private final MissionAdapter mAdapter; - private final DownloadManager mDownloadManager; - private final MissionIterator mIterator; - private final Handler mHandler; - private final View mView; - - public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { - mView = v; - mContext = c; - mAdapter = a; - mDownloadManager = d; - mIterator = i; - mHandler = h; - - items = new ArrayList<>(2); - } - - public void append(Mission item, boolean alsoDeleteFile) { - /* If a mission is removed from the list while the Snackbar for a previously - * removed item is still showing, commit the action for the previous item - * immediately. This prevents Snackbars from stacking up in reverse order. - */ - mHandler.removeCallbacksAndMessages(COMMIT); - commit(); - - mIterator.hide(item); - items.add(0, new Pair<>(item, alsoDeleteFile)); - - show(); - } - - private void forget() { - mIterator.unHide(items.remove(0).getFirst()); - mAdapter.applyChanges(); - - show(); - } - - private void show() { - if (items.size() < 1) return; - - pause(); - running = true; - - HandlerCompat.postDelayed(mHandler, this::next, NEXT, DELAY); - } - - private void next() { - if (items.size() < 1) return; - - final Optional fileToBeDeleted = items.stream() - .filter(Pair::getSecond) - .map(p -> p.getFirst().storage.getName()) - .findFirst(); - - String msg; - if (fileToBeDeleted.isPresent()) { - msg = mContext.getString(R.string.file_deleted) - .concat(":\n") - .concat(fileToBeDeleted.get()); - } else { - msg = mContext.getString(R.string.entry_deleted); - } - - snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); - snackbar.setAction(R.string.undo, s -> forget()); - snackbar.setActionTextColor(Color.YELLOW); - snackbar.show(); - - HandlerCompat.postDelayed(mHandler, this::commit, COMMIT, TIMEOUT); - } - - private void commit() { - if (items.size() < 1) return; - - while (items.size() > 0) { - Pair missionAndAlsoDeleteFile = items.remove(0); - Mission mission = missionAndAlsoDeleteFile.getFirst(); - boolean alsoDeleteFile = missionAndAlsoDeleteFile.getSecond(); - if (mission.deleted) continue; - - mIterator.unHide(mission); - mDownloadManager.deleteMission(mission, alsoDeleteFile); - - if (mission instanceof FinishedMission) { - mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); - } - break; - } - - if (items.size() < 1) { - pause(); - return; - } - - show(); - } - - public void pause() { - running = false; - mHandler.removeCallbacksAndMessages(NEXT); - mHandler.removeCallbacksAndMessages(SHOW); - mHandler.removeCallbacksAndMessages(COMMIT); - if (snackbar != null) snackbar.dismiss(); - } - - public void resume() { - if (!running) { - HandlerCompat.postDelayed(mHandler, this::show, SHOW, DELAY_RESUME); - } - } - - public void dispose() { - if (items.size() < 1) return; - - pause(); - - for (Pair missionAndAlsoDeleteFile : items) { - Mission mission = missionAndAlsoDeleteFile.getFirst(); - boolean alsoDeleteFile = missionAndAlsoDeleteFile.getSecond(); - mDownloadManager.deleteMission(mission, alsoDeleteFile); - } - items = null; - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java deleted file mode 100644 index 2a8077d51..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java +++ /dev/null @@ -1,132 +0,0 @@ -package us.shandian.giga.ui.common; - -import android.graphics.Canvas; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.PixelFormat; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.os.Handler; -import android.os.Looper; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; - -public class ProgressDrawable extends Drawable { - private static final int MARQUEE_INTERVAL = 150; - - private float mProgress; - private int mBackgroundColor, mForegroundColor; - private Handler mMarqueeHandler; - private float mMarqueeProgress; - private Path mMarqueeLine; - private int mMarqueeSize; - private long mMarqueeNext; - - public ProgressDrawable() { - mMarqueeLine = null;// marquee disabled - mMarqueeProgress = 0.0f; - mMarqueeSize = 0; - mMarqueeNext = 0; - } - - public void setColors(@ColorInt int background, @ColorInt int foreground) { - mBackgroundColor = background; - mForegroundColor = foreground; - } - - public void setProgress(double progress) { - mProgress = (float) progress; - invalidateSelf(); - } - - public void setMarquee(boolean marquee) { - if (marquee == (mMarqueeLine != null)) { - return; - } - mMarqueeLine = marquee ? new Path() : null; - mMarqueeHandler = marquee ? new Handler(Looper.getMainLooper()) : null; - mMarqueeSize = 0; - mMarqueeNext = 0; - } - - @Override - public void draw(@NonNull Canvas canvas) { - int width = getBounds().width(); - int height = getBounds().height(); - - Paint paint = new Paint(); - - paint.setColor(mBackgroundColor); - canvas.drawRect(0, 0, width, height, paint); - - paint.setColor(mForegroundColor); - - if (mMarqueeLine != null) { - if (mMarqueeSize < 1) setupMarquee(width, height); - - int size = mMarqueeSize; - Paint paint2 = new Paint(); - paint2.setColor(mForegroundColor); - paint2.setStrokeWidth(size); - paint2.setStyle(Paint.Style.STROKE); - - size *= 2; - - if (mMarqueeProgress >= size) { - mMarqueeProgress = 1; - } else { - mMarqueeProgress++; - } - - // render marquee - width += size * 2; - Path marquee = new Path(); - for (int i = -size; i < width; i += size) { - marquee.addPath(mMarqueeLine, ((float)i + mMarqueeProgress), 0); - } - marquee.close(); - - canvas.drawPath(marquee, paint2);// draw marquee - - if (System.currentTimeMillis() >= mMarqueeNext) { - // program next update - mMarqueeNext = System.currentTimeMillis() + MARQUEE_INTERVAL; - mMarqueeHandler.postDelayed(this::invalidateSelf, MARQUEE_INTERVAL); - } - return; - } - - canvas.drawRect(0, 0, (int) (mProgress * width), height, paint); - } - - @Override - public void setAlpha(int alpha) { - // Unsupported - } - - @Override - public void setColorFilter(ColorFilter filter) { - // Unsupported - } - - @Override - public int getOpacity() { - return PixelFormat.OPAQUE; - } - - @Override - public void onBoundsChange(Rect rect) { - if (mMarqueeLine != null) setupMarquee(rect.width(), rect.height()); - } - - private void setupMarquee(int width, int height) { - mMarqueeSize = (int) ((width * 10.0f) / 100.0f);// the size is 10% of the width - - mMarqueeLine.rewind(); - mMarqueeLine.moveTo(-mMarqueeSize, -mMarqueeSize); - mMarqueeLine.lineTo(-mMarqueeSize * 4, height + mMarqueeSize); - mMarqueeLine.close(); - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java deleted file mode 100644 index 1542d3ff0..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java +++ /dev/null @@ -1,24 +0,0 @@ -package us.shandian.giga.ui.common; - -import android.os.Bundle; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; - -import org.schabi.newpipe.R; - -public abstract class ToolbarActivity extends AppCompatActivity { - protected Toolbar mToolbar; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(getLayoutResource()); - - mToolbar = this.findViewById(R.id.toolbar); - - setSupportActionBar(mToolbar); - } - - protected abstract int getLayoutResource(); -} diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java deleted file mode 100644 index ddd9ba426..000000000 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ /dev/null @@ -1,342 +0,0 @@ -package us.shandian.giga.ui.fragment; - -import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.os.IBinder; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.FilePickerActivityHelper; - -import java.io.File; -import java.io.IOException; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.service.DownloadManager; -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; -import us.shandian.giga.ui.adapter.MissionAdapter; - -public class MissionsFragment extends Fragment { - - private static final String TAG = "MissionsFragment"; - private static final int SPAN_SIZE = 2; - - private SharedPreferences mPrefs; - private boolean mLinear; - private MenuItem mSwitch; - private MenuItem mClear = null; - private MenuItem mStart = null; - private MenuItem mPause = null; - - private RecyclerView mList; - private View mEmpty; - private MissionAdapter mAdapter; - private GridLayoutManager mGridManager; - private LinearLayoutManager mLinearManager; - private Context mContext; - - private DownloadManagerBinder mBinder; - private boolean mForceUpdate; - - private DownloadMission unsafeMissionTarget = null; - private final ActivityResultLauncher requestDownloadSaveAsLauncher = - registerForActivityResult(new StartActivityForResult(), this::requestDownloadSaveAsResult); - private final ServiceConnection mConnection = new ServiceConnection() { - - @Override - public void onServiceConnected(ComponentName name, IBinder binder) { - mBinder = (DownloadManagerBinder) binder; - mBinder.clearDownloadNotifications(); - - mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty, getView()); - - mAdapter.setRecover(MissionsFragment.this::recoverMission); - - setAdapterButtons(); - - mBinder.addMissionEventListener(mAdapter); - mBinder.enableNotifications(false); - - updateList(); - } - - @Override - public void onServiceDisconnected(ComponentName name) { - // What to do? - } - - - }; - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View v = inflater.inflate(R.layout.missions, container, false); - - mPrefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()); - mLinear = mPrefs.getBoolean("linear", false); - - // Bind the service - mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); - - // Views - mEmpty = v.findViewById(R.id.list_empty_view); - mList = v.findViewById(R.id.mission_recycler); - - // Init layouts managers - mGridManager = new GridLayoutManager(getActivity(), SPAN_SIZE); - mGridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - switch (mAdapter.getItemViewType(position)) { - case DownloadManager.SPECIAL_PENDING: - case DownloadManager.SPECIAL_FINISHED: - return SPAN_SIZE; - default: - return 1; - } - } - }); - mLinearManager = new LinearLayoutManager(getActivity()); - - setHasOptionsMenu(true); - - return v; - } - - /** - * Added in API level 23. - */ - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - - // Bug: in api< 23 this is never called - // so mActivity=null - // so app crashes with null-pointer exception - mContext = context; - } - - /** - * deprecated in API level 23, - * but must remain to allow compatibility with api<23 - */ - @SuppressWarnings("deprecation") - @Override - public void onAttach(@NonNull Activity activity) { - super.onAttach(activity); - - mContext = activity; - } - - - @Override - public void onDestroy() { - super.onDestroy(); - if (mBinder == null || mAdapter == null) return; - - mBinder.removeMissionEventListener(mAdapter); - mBinder.enableNotifications(true); - mContext.unbindService(mConnection); - mAdapter.onDestroy(); - - mBinder = null; - mAdapter = null; - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - mSwitch = menu.findItem(R.id.switch_mode); - mClear = menu.findItem(R.id.clear_list); - mStart = menu.findItem(R.id.start_downloads); - mPause = menu.findItem(R.id.pause_downloads); - - if (mAdapter != null) setAdapterButtons(); - - super.onPrepareOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.switch_mode) { - mLinear = !mLinear; - updateList(); - return true; - } else if (itemId == R.id.clear_list) { - showClearDownloadHistoryPrompt(); - return true; - } else if (itemId == R.id.start_downloads) { - mBinder.getDownloadManager().startAllMissions(); - return true; - } else if (itemId == R.id.pause_downloads) { - mBinder.getDownloadManager().pauseAllMissions(false); - mAdapter.refreshMissionItems();// update items view - - return super.onOptionsItemSelected(item); - } - return super.onOptionsItemSelected(item); - } - - public void showClearDownloadHistoryPrompt() { - // ask the user whether he wants to just clear history or instead delete files on disk - new AlertDialog.Builder(mContext) - .setTitle(R.string.clear_download_history) - .setMessage(R.string.confirm_prompt) - // Intentionally misusing buttons' purpose in order to achieve good order - .setNegativeButton(R.string.clear_download_history, (dialog, which) -> - mAdapter.clearFinishedDownloads(false)) - .setNeutralButton(R.string.cancel, null) - .setPositiveButton(R.string.delete_downloaded_files, (dialog, which) -> - showDeleteDownloadedFilesConfirmationPrompt()) - .show(); - } - - public void showDeleteDownloadedFilesConfirmationPrompt() { - // make sure the user confirms once more before deleting files on disk - new AlertDialog.Builder(mContext) - .setTitle(R.string.delete_downloaded_files_confirm) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> - mAdapter.clearFinishedDownloads(true)) - .show(); - } - - private void updateList() { - if (mLinear) { - mList.setLayoutManager(mLinearManager); - } else { - mList.setLayoutManager(mGridManager); - } - - // destroy all created views in the recycler - mList.setAdapter(null); - mAdapter.notifyDataSetChanged(); - - // re-attach the adapter in grid/lineal mode - mAdapter.setLinear(mLinear); - mList.setAdapter(mAdapter); - - if (mSwitch != null) { - mSwitch.setIcon(mLinear - ? R.drawable.ic_apps - : R.drawable.ic_list); - mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); - mPrefs.edit().putBoolean("linear", mLinear).apply(); - } - } - - private void setAdapterButtons() { - if (mClear == null || mStart == null || mPause == null) return; - - mAdapter.setClearButton(mClear); - mAdapter.setMasterButtons(mStart, mPause); - } - - private void recoverMission(@NonNull DownloadMission mission) { - unsafeMissionTarget = mission; - - final Uri initialPath; - if (NewPipeSettings.useStorageAccessFramework(mContext)) { - initialPath = null; - } else { - final File initialSavePath; - if (DownloadManager.TAG_AUDIO.equals(mission.storage.getType())) { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - } else { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - } - initialPath = Uri.parse(initialSavePath.getAbsolutePath()); - } - - NoFileManagerSafeGuard.launchSafe( - requestDownloadSaveAsLauncher, - StoredFileHelper.getNewPicker(mContext, mission.storage.getName(), - mission.storage.getType(), initialPath), - TAG, - mContext - ); - } - - @Override - public void onResume() { - super.onResume(); - - if (mAdapter != null) { - mAdapter.onResume(); - - if (mForceUpdate) { - mForceUpdate = false; - mAdapter.forceUpdate(); - } - - mBinder.addMissionEventListener(mAdapter); - mAdapter.checkMasterButtonsVisibility(); - } - if (mBinder != null) mBinder.enableNotifications(false); - } - - @Override - public void onPause() { - super.onPause(); - - if (mAdapter != null) { - mForceUpdate = true; - mBinder.removeMissionEventListener(mAdapter); - mAdapter.onPaused(); - } - - if (mBinder != null) mBinder.enableNotifications(true); - } - - private void requestDownloadSaveAsResult(final ActivityResult result) { - if (result.getResultCode() != Activity.RESULT_OK) { - return; - } - - if (unsafeMissionTarget == null || result.getData() == null) { - return; - } - - try { - Uri fileUri = result.getData().getData(); - if (fileUri.getAuthority() != null && FilePickerActivityHelper.isOwnFileUri(mContext, fileUri)) { - fileUri = Uri.fromFile(Utils.getFileForUri(fileUri)); - } - - String tag = unsafeMissionTarget.storage.getTag(); - unsafeMissionTarget.storage = new StoredFileHelper(mContext, null, fileUri, tag); - mAdapter.recoverMission(unsafeMissionTarget); - } catch (IOException e) { - Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show(); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java deleted file mode 100644 index 86a08c57f..000000000 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ /dev/null @@ -1,276 +0,0 @@ -package us.shandian.giga.util; - -import android.content.Context; -import android.os.Build; -import android.os.Environment; -import android.os.StatFs; -import android.util.Log; - -import androidx.annotation.ColorInt; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.google.android.exoplayer2.util.Util; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; - -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.Serializable; -import java.net.HttpURLConnection; -import java.util.Locale; - -import okio.ByteString; - -public class Utility { - - public enum FileType { - VIDEO, - MUSIC, - SUBTITLE, - UNKNOWN - } - - public static String formatBytes(long bytes) { - Locale locale = Locale.getDefault(); - if (bytes < 1024) { - return String.format(locale, "%d B", bytes); - } else if (bytes < 1024 * 1024) { - return String.format(locale, "%.2f kB", bytes / 1024d); - } else if (bytes < 1024 * 1024 * 1024) { - return String.format(locale, "%.2f MB", bytes / 1024d / 1024d); - } else { - return String.format(locale, "%.2f GB", bytes / 1024d / 1024d / 1024d); - } - } - - public static String formatSpeed(double speed) { - Locale locale = Locale.getDefault(); - if (speed < 1024) { - return String.format(locale, "%.2f B/s", speed); - } else if (speed < 1024 * 1024) { - return String.format(locale, "%.2f kB/s", speed / 1024); - } else if (speed < 1024 * 1024 * 1024) { - return String.format(locale, "%.2f MB/s", speed / 1024 / 1024); - } else { - return String.format(locale, "%.2f GB/s", speed / 1024 / 1024 / 1024); - } - } - - public static void writeToFile(@NonNull File file, @NonNull Serializable serializable) { - - try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { - objectOutputStream.writeObject(serializable); - } catch (Exception e) { - //nothing to do - } - //nothing to do - } - - @Nullable - @SuppressWarnings("unchecked") - public static T readFromFile(File file) { - T object; - - try (ObjectInputStream objectInputStream = - new ObjectInputStream(new FileInputStream(file))) { - object = (T) objectInputStream.readObject(); - } catch (Exception e) { - Log.e("Utility", "Failed to deserialize the object", e); - object = null; - } - - return object; - } - - @Nullable - public static String getFileExt(String url) { - int index; - if ((index = url.indexOf("?")) > -1) { - url = url.substring(0, index); - } - - index = url.lastIndexOf("."); - if (index == -1) { - return null; - } else { - String ext = url.substring(index); - if ((index = ext.indexOf("%")) > -1) { - ext = ext.substring(0, index); - } - if ((index = ext.indexOf("/")) > -1) { - ext = ext.substring(0, index); - } - return ext.toLowerCase(); - } - } - - public static FileType getFileType(char kind, String file) { - switch (kind) { - case 'v': - return FileType.VIDEO; - case 'a': - return FileType.MUSIC; - case 's': - return FileType.SUBTITLE; - //default '?': - } - - if (file.endsWith(".srt") || file.endsWith(".vtt") || file.endsWith(".ssa")) { - return FileType.SUBTITLE; - } else if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a") || file.endsWith(".opus")) { - return FileType.MUSIC; - } else if (file.endsWith(".mp4") || file.endsWith(".mpeg") || file.endsWith(".rm") || file.endsWith(".rmvb") - || file.endsWith(".flv") || file.endsWith(".webp") || file.endsWith(".webm")) { - return FileType.VIDEO; - } - - return FileType.UNKNOWN; - } - - @ColorInt - public static int getBackgroundForFileType(Context ctx, FileType type) { - int colorRes; - switch (type) { - case MUSIC: - colorRes = R.color.audio_left_to_load_color; - break; - case VIDEO: - colorRes = R.color.video_left_to_load_color; - break; - case SUBTITLE: - colorRes = R.color.subtitle_left_to_load_color; - break; - default: - colorRes = R.color.gray; - } - - return ContextCompat.getColor(ctx, colorRes); - } - - @ColorInt - public static int getForegroundForFileType(Context ctx, FileType type) { - int colorRes; - switch (type) { - case MUSIC: - colorRes = R.color.audio_already_load_color; - break; - case VIDEO: - colorRes = R.color.video_already_load_color; - break; - case SUBTITLE: - colorRes = R.color.subtitle_already_load_color; - break; - default: - colorRes = R.color.gray; - break; - } - - return ContextCompat.getColor(ctx, colorRes); - } - - @DrawableRes - public static int getIconForFileType(FileType type) { - switch (type) { - case MUSIC: - return R.drawable.ic_headset; - default: - case VIDEO: - return R.drawable.ic_movie; - case SUBTITLE: - return R.drawable.ic_subtitles; - } - } - - public static String checksum(final StoredFileHelper source, final int algorithmId) - throws IOException { - ByteString byteString; - try (var inputStream = new SharpInputStream(source.getStream())) { - byteString = ByteString.of(Util.toByteArray(inputStream)); - } - if (algorithmId == R.id.md5) { - byteString = byteString.md5(); - } else if (algorithmId == R.id.sha1) { - byteString = byteString.sha1(); - } - return byteString.hex(); - } - - @SuppressWarnings("ResultOfMethodCallIgnored") - public static boolean mkdir(File p, boolean allDirs) { - if (p.exists()) return true; - - if (allDirs) - p.mkdirs(); - else - p.mkdir(); - - return p.exists(); - } - - public static long getContentLength(HttpURLConnection connection) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return connection.getContentLengthLong(); - } - - try { - return Long.parseLong(connection.getHeaderField("Content-Length")); - } catch (Exception err) { - // nothing to do - } - - return -1; - } - - /** - * Get the content length of the entire file even if the HTTP response is partial - * (response code 206). - * @param connection http connection - * @return content length - */ - public static long getTotalContentLength(final HttpURLConnection connection) { - try { - if (connection.getResponseCode() == 206) { - final String rangeStr = connection.getHeaderField("Content-Range"); - final String bytesStr = rangeStr.split("/", 2)[1]; - return Long.parseLong(bytesStr); - } else { - return getContentLength(connection); - } - } catch (Exception err) { - // nothing to do - } - - return -1; - } - - private static String pad(int number) { - return number < 10 ? ("0" + number) : String.valueOf(number); - } - - public static String stringifySeconds(final long seconds) { - final int h = (int) Math.floorDiv(seconds, 3600); - final int m = (int) Math.floorDiv(seconds - (h * 3600L), 60); - final int s = (int) (seconds - (h * 3600) - (m * 60)); - - String str = ""; - - if (h < 1 && m < 1) { - str = "00:"; - } else { - if (h > 0) str = pad(h) + ":"; - if (m > 0) str += pad(m) + ":"; - } - - return str + pad(s); - } -} diff --git a/app/src/main/res/animator/custom_fade_in.xml b/app/src/main/res/animator/custom_fade_in.xml deleted file mode 100644 index f8df118cc..000000000 --- a/app/src/main/res/animator/custom_fade_in.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/animator/custom_fade_out.xml b/app/src/main/res/animator/custom_fade_out.xml deleted file mode 100644 index 3f71e5c58..000000000 --- a/app/src/main/res/animator/custom_fade_out.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png deleted file mode 100644 index dd36385796e69e2a480737148e44d95effdeba5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 655 zcmV;A0&x9_P)8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10vt(1 zK~z|U)!4gh6j2z);a?@~HGsARLt5w=5 z^JW_`Gjk6lX_=(b;90(CAMirbmZC9`ePkPeVMzm$mbU{4paVDne2{cr(t_Cr#pwdh z0i%+3=Ijajm#qaJOB$54svSUab_3&*`XnuG2N1v_pci-}>0n5sRzVL_uCE08B|VX} zu1wG&U>h)&vc4S{lXOGUk}^QpehO58W5BmMC7nPI@J`aOCG|?0UnnR6 z8wAz^*HTKB0)4MX3Ng8m!lVA>eiLVrSk8Nh^R$z<#DjR4+q7kL~f93%P-! z{csex0HobQJ_BcL52W?U1te(?a2;5kQZfNt0WRB~Xw)kRBxxb=0NB-_>^^YH_Qxze zLw}*J2AKE2N!yQ_X_)(_27Urpfs3~PH2dK^K~umz;I!?ptv1OEGzuKIJrdd|6jTF_ z0C#Nv4aGnp$o3E5b{>#8t=u}E0l)G@B-0G&3(=FJ?cW7?GL5Xp^uyQLXhel33#i|= pi-yGc@5pRVl>vz}_udbcoqz8anS{wSRKWlM002ovPDHLkV1k0E5PSdt diff --git a/app/src/main/res/drawable-hdpi/ic_newpipe_update.png b/app/src/main/res/drawable-hdpi/ic_newpipe_update.png deleted file mode 100755 index 047d2f798f9cc5adc791fff1eebf9dfda112658d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 525 zcmV+o0`mQdP)oZH67zz^+LM?y9#&t(uW4|;zr>{$nCGqnAz>%^+#1dF2eF0fBEd!S`8p!ApnMH$qa4sSNcZO;uD*sH^vE>SN00`X%!Bo zrO_MWdKT#Gq)>Y5Tw#w;j-2Lulg+olEWn4`v3p{8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10c}Y{ zK~zYI)z&?0ltC25@!!glM#Mq|!6ukOE$l>Mm(L--oI)&ouProw11&66aIb0n4i+|o zVjw{q6;wpfLV}=0I*AtU!lNYmJZrY_R5SO^J^#7G3`}n@7?`BCOw@fNK$7VsdnX3K z3|=O=k!0H>05F9ML(qvNz0C%UvT8!BwE?_`s(RhUc%(la(MLaD?3rwaCaHrrj3ht7~^oYA8wzc)8Nt%|q`+w%XU3>a@C8U&DjwS~d zi~O)AIY65?<|G54!g1r00gz>5qM#iRd1qcC;H(@GfD9Ij?3iT4)GcgwE&30`7SQ8&)3- UTM~b@DgXcg07*qoM6N<$g3+CVod5s; diff --git a/app/src/main/res/drawable-mdpi/volunteer_activism_ic.xml b/app/src/main/res/drawable-mdpi/volunteer_activism_ic.xml deleted file mode 100644 index de6985c53..000000000 --- a/app/src/main/res/drawable-mdpi/volunteer_activism_ic.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night-v23/splash_background.xml b/app/src/main/res/drawable-night-v23/splash_background.xml deleted file mode 100644 index 76f5bada6..000000000 --- a/app/src/main/res/drawable-night-v23/splash_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_heart.xml b/app/src/main/res/drawable-night/ic_heart.xml deleted file mode 100644 index 6128a3d0d..000000000 --- a/app/src/main/res/drawable-night/ic_heart.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/splash_background.xml b/app/src/main/res/drawable-night/splash_background.xml deleted file mode 100644 index 237f4cdae..000000000 --- a/app/src/main/res/drawable-night/splash_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/background_header.png b/app/src/main/res/drawable-nodpi/background_header.png deleted file mode 100644 index e00e9a21fc1c7d4fcf247b0bc7e5d82b51585bbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15393 zcmdse2UnBN6E2E?C`xaJBBA$A2-OmrbP$l-p{5w-;9RSBQ_Z20G8xh~@v6 zAG7@y3*w7u`WfjNliavTdW(#l;x^@-yHxk?Q`6AW(K9eIF|)8f0I;!ha6aS$a`W)= z@e2qF35$q|iAzXINz2H}f#ksoib|@Fo;-!9YiMdc(;ZUPpe7+vNYsKnejYTxGaqXM zn43l)xbDT@xpkV2iNjpCDlGB|KF4|9mkWrZVR_7}wRinbZ_Snd&Qwu@S;M~wCHU_f zt*ctE`k6->+X(2*^lONc&@LF)6s<8O>iw+deDJy#enP!i;f;LvuQN$w8F=F69i&z5 z$YIUX)>z&_p_g<03z@mA`uocAr|^|$+n3xUw??~y)|{r`rB!*uk0pVF=|!#%EhMR> zID^T~<2USumIj#Zx3H%jB(xjqlcO|DU?N2&1MqYhC63W#d9x)^f^%#0HOv1P2a`6J zq)#I#nJv{~Z6yoL2vpx}7x!-z;@n!?&+n9Olc$h%(-}j_?ePTlVU%^kmnPefn&173 zHb&u@9OLItR4ZZ9w<2C&#KDSiFN$z}4^do)6hpdqEtdT3wrLk%?-@fqT0R`~S?{YD zLeo&sFx%=GTrH$z5$Fnl8_U>R0{j0|B^pmRQuT=k#R`CibO+07$NNy`BMT6WAyi1u zvNuvJDD}Svc6qORq|c=C=@(xQA@R0kdE$5Q92ES*+fXyUj2xi=`-k$0)9+_)RG_HJg z7`F~L{&?WxH-v=M+cS)6MSu&qFAbnEu6Y}1Hx{+v31Yt;nkn^3q!)_@e4eQ6AO!)d zzn|#T`@thd7Ed=Ly|8gE>+sx|-x29>##Nh^2km#g`U+ zj)A?D;@$)7opHO5Hqb&Wsf)`^rJ*@6)^@|^X{fSlCI%+;y-7dPiuSf4*=KoA9NCM$o@fO8>V-J~d!j!E~TS$sXeNYdyb5BvQ? z-a{fik@fdwJ*n1fJ218 zyFVkEp%2;^k`HkvqhDt1SNTps9ItLH{pskDnwO<_B_221vT`PSsuSMAwE!eFfL7BX zfE@x!6I&e%I0h8SioD04yc@>0OU0JtA1nDvQPbr!MiRr+(=c`Eg9QF-s00~ z1ynTN>#q@Q5@mY|wX!N8Sk^-yZ)|>)`n$T7zgTPLMq2Pg;>#B_s1T=fme9}ZT}Nq? zkWww%E$%&y@Q@z)<||t!{NM;D>Jr`g?W{#^Q$R+rJ3fyq%E0m$rFe}cN&QhsL77p^ zMoK&=>(y3~Ltl~I&h3Ev%p+)@gt^tzSGZ-VBHbYUZioz(mp*jlU@QMs&fb8wNnf}x z>BY=(aqH>G^Xt!8-O&Z5n&*B+E6OF3clxW*l|T6;KAe3`u;JoV=4LSf@K?{NYJPc5 zxXJFWdIkHk`PnSIwFEE~(1+^k)-;#133l_26o|i$<>G0p8)WmY6;*UnJl%2r8e`nl zK8r$$isQuS`)A&U^uqnq579{W_kj8kXTSB2V~&KNUbhNeyC5W{X<2x_@n%7BcB!|D z$K1G*mHf%wQ&Gj9T`}$x$(Po!pN;N~{Gh1}=6S^s--z`6nBO_4@-zRSGrF!3ZMpSW zuU={Q{K@*(6f>&_;Mi`G(@-Yq`Jo_PE4saQn~!&LU&{oH*{_h0O6SCX_I1``ST#+_2?P+s(lwG9%OSV)8f(ag~gWKKzN;UE`!|cLA{av{wBn=RpVx@k} zcY#E}xI3GN6PGU7<%QPjMnYA9GHOTxP^@^yKbSFGho>EJ*8EfDb1Y=QQb>DSYtaY7 z(|O4Da7Y_3doX~KvwW1EdG@CJOc;Uze}76ggcMRKII7c7^_hOhfj!bGI|{3!;u(D5WzE0koGnAvomHtZhZ#66ilU2)|z$q_w&Z{`Fqd1vjt#E=` z{u1^zw%iF^h-R;=yB0rKxt`UW%kRG)EajP!Lk!}%*!FgY#INrkw*wmcGxOYJ!_|5MAvLZ7UFnl=dIQf-w*{2G`fH zNCRXiHf&4C3W^o<`tb{Zq}Tx)F3$?hcRvzv2m~y3)QH2qWjmc%!#e&&;}C8pJ1+GI zjw~2=;r1pNgbLkK1Q9hl4I`G^TWAfT7i#L>I`pXs*wOII?Z3HFBZ45H1>>pw{LZ>M zL}szajP}x_9gQmDL*x9o+hNB8CA8*!oI&^P$enSfeUd~8On3jv*vbzO{Hi?My)DNO z2-U+y-D9uE1&1Z14-{se_zn8>NXo_ezYj6H-@c9}(+qnAnH=>UsA0d14`O(r;G*IG zM=cGtv0*?Tq}xBwowKKw1_+dO{e*k&SGnqj5CH8eI7;|WYD3v3`3w1tuPnPFhv;*m zkX$tC9ipR`s_eF3nPgHF0!DAGLDh9Oc;_AnXv@I4{SqI`A=iZ2yL!19l#0-woDF@l z@Enax?6VDxaY6CsQOW}M5}3>I$)|&fwf|{x2vXyiOuv&Hcm$4_H1l@g(xP>b!xa5~xJ>Qx-vCy5nv2o%Gq@7)J}n&Ovcji^2P$ z0_X7o!`@!q_mgwAzWc#fS?3$=Vyn#hO5al8Gkvi4ljR-md=VOxCU-qukU{$?8GB_) z7B~iqs-~+M-wHtR=)8vEGZQ~nbNRRVNTXV@y%n2-CpVJqcQx73iBCn9Qfu42Oi81XxNThW4=Cru~d|4LJhIRKtz2U`6_6s{_W0g5wQKJ2iAeccTG+LluX*24l zNy5NP6BQbF?EUTdg(1&TH1(%OSs>^4LyB}k(5qobzOs%ZbEYFPqMrkDPV7_M8`51~ zs6BV0zlER(Wke584SjhpN! z;xY?1V=Fn<>d8v->P5}VDo@%AxrPDZcr0DV&XqVeJiz-{55rn8@WwuR!8_^IPBq$f zezBO=6+*$_4(+;{Ll=4vjV9IuC&Fq@UVSbmRnP(By~La$K)H*T%uv_6Sa{UNN+4-ecqSg><)W>7NJH=MLR`IeGnIaDxn@4-FBG7<79=f5#r;L6d zyjU|#d&>Z)%KA>uY@fukM5^Lr*`bwQ^L$xkiE<7K(6f!|^{gO#CPnyMWmZb%)p4tC zXvDTWB((o|7EF5cCt9?;@65{<>Jjr}C)E8%pQO8`>UqJ(My$8R&_Y7iJ7JDQ$70+W zLX2@Chj}{AQDau3I6%>H2EEZ-DaId>@3tq-_D`1j;_;=jHaC{yh1*cfpyY5zazMQqaewr;$As@^7=#y}g&Uo?Fj58IfJe=8-hg+_-M7Yy(6 z1ufB^4Nf8{@N99jAxJ#Y0w;1i*Dpe1j!TB-O&If;Tv1wlvZNeVQ|_L3eJr%I^RxX+ zAe}T_tGN?x=v$A)02B)8+eq}-FB`hRO|H(4*7~;g!&?v@7af-OWzY z0z^+e+Qmu*&cQ}LRw<~6)wMjbVgkpKc4xnp+P6qih{YIuA;R(GRHFA(d7d~Bkq`uJ z-&Fv~Vnqo`piasim{~q&iY574Ky{bP+kvtJ8f)G}H>QIw+-VL#mGD-8kUo24{8PR4 z4lS^hj~sWAbWq&3KJjLm&6>dHtkD?k8o@EcZA~^IYW#l0Ar>ClO)~NcbkADrXn-_d z<9?!sOSH?vcd)Nq08}$iGH0uuTE1f}hx!$!-FE8TNwkE+ofPlZ%-+Xoi_?C1hsM(h zMMTP+^`PKU8HG>vf-C0xk3MN=h8mP$BSqp_;l;uQN}|P_s;r#1>>r4q_DHJqGl2QrL>F9>Qs=8^%(!rf(3O~Yy0*C^ng6MWGHWvUU6H0Nv_zk zt=GPpWalXT(xw>8^IVc*xxc%8IUO+iAkpAc*#bT#kHl|Il8P36gw37!LW4(iC;rlG z7RLRpN~Oa7#{#wo+I^u8oGb=mWZzkeFn&tqd*ma?)-_PYoNU3-R<(;nyTY`f@9=f^b+GbU5S_H(B|-iEic~;JE%qU` zAD4KaGI{1}_FYRI{llGrkW<~mf&dWJs~sAw0H`mMl^|&GSy)%((V`)A&DS>1->zQ? z4cb0xX^F!z7j) zLPTHtg(5l2=c?JK81U&tLa}s<>9{mII))!OghVvs<#pmTVUK5^RPSG;FLABWvZK>P zz>MkzJ0fsyVBxk)7;qPd1}r863r&32HGv%>?vXKZl4IG#POf^Gm%Y=>ev6W;k zbo9yk;X1RsDYOd<#MfLDFIy0|E!kH>>8Nf9Z61s=m1{!fJHT>EL5ICB@6buCuIg}r zH@`Yv&AK5h%@~?5`LCy-F7tXESblQ+#q+kTwPj6qrHIP}lqQ)zIh6b%aE%^I0)ZGg zpha84J96i)IkDG-UCw(3fFkO%YAi7}M$AXa|SGlK7JXuTlT-{X-9uX?t``j`Vl9 z4#TFr?tPR^`Vpo+laup0F6g04%Y%pI=jAO=AnKbW?{r@K#`Fk+U_CXO!~g-L2VM;! zK9Wkr@BjmhFtq>CirM)jQ=m`_GNv6sUD-~ykrPq8dN7iN%6*Egm3T##svS>~K@+JHAfrZ^^ zsPQr$drn%>h;$SpNiCQEKPkB|_!CGVN&w_Q>{ECe{2wh1kOzi12@%!AWy^Vj)8Ggl zo3xN7#P)mEN4NbeaAA6(-IR3ZB9Z1gGfDCtYA_tNA;O~#mMVsjXXQ+<2;4=H0^Lsm|fgkQNOE1;W zyR}Z#uc0?2R*x*1HK-Vb)xQh|eXQOxHsM!t1=y609z1o6X}yy~$Vs7;fbTfU4NP`r z#Xs&vw0T)V#=xXLc6REXelGQKxo{<4 zJCcv}rA9f>oq%M;lRA$nA&?Z;^mo$VQ2>rbs{IRDC8Z!jKD%?*Wc>Fx-v$ZlkesQm z@yy>j%s1(2rIl#s7|N~>HC84mfMx`1itAhNBy;A%+ZadyyV^PSxgXC{W?X*#p(UON>tb6k`nTTj`}8=; zCCm+%`#)99knXQO60Xt<8M*phIY^j>WB_a8rJ;Hxbk<((b<14IA?` zKhBd*t+Ng3!T9bF&VZdG=Uh3XFVbtD`T25B2&mSbVwusB~#gItu#ap&8*V^_xtjnT~ zmkxK=RvA1%pF3c2X47`+67_a+2e@W|_)G5o)L<%_X}=*`uuY4br{3$4&5M-hY&{3A z*UnbvV-(KJ7eWn#nMN)3#Yc!fyuS+ zhM9NZ-9O~}HkDNyye6kdAu#%WEjsBB^2?{SZ|EQ1&z?_zSIFS@v&Fq{R5uqL=V%8G zJx$&p1c zaoWFpSV@z-jSwgNN>PpLn{ZHV>Ai8=9k>|~$URj5XHyt6m_a)M#@~u-cD^!*kON&wsX=%% z-oxGuRpwIosCh6>uWo!`SbTX zjwh2=s&RwNy7iZPxAz54ohHY86s-QmLp(f@XdN5yr5pl{;+*K*)eZ<=TB&9tyxMRI z*z)_8soidxk7%_Bmj{hECD!vn2fuC7Vx7SR?ai58$JnytAuiQ*^ED>g$T`AtDyiZX zeUATVnp#qKujuuVm|3nQGI-mz={E&T+V(KjYV&K%?zs%6sx>-cxa zU%dTKSln?0o6lXnr~UMGzCt~=*sJ$|!1N;sEF?`~N?ot5m3cUj+IUyU8D=$9G<5bp zQA2k)Q|(yLBgibjH_Y;T1MrUjIF%v5aCx(GX$;w_m&Fm$TZGEtyczm-xp#fsdN>pO zcU|Q(=rF%p%`k0b?=CT5J4&;;yIR$qcAcSf)}^SDf!YnXOgcEr+ek?kPI#u#O-^5J%0USGxw zo>p8cAdp?s@ZLMRe1<+x%X}Us4`dlUap0a6z|i|5VV>xdM~mdwN9WN{+a&#r<4;!;Jd|qT>zk6J^vHm=;^asEY`uVrqsC55b6T=62!p2|6 z-|>A|LcpYhO*T@leJ>1T{hg0L4$z0xzk%-|xrRn*KbNmb5e!4MZFd4xj*bh>O+FE$ z-~--`HX5|-Qlw{!c5TJP{@h;Lgpsokjo-#ajlEB3Hq@tit%VJ|ov1jHRYDw()zbj9 ze~3SZ-bC2-UxgM=uhR}D2FoTM#Cpol|0f|Sc{V=E%Obzx&J{0ReZhd72={$jIe=ce z0O;4f%jCNCI0S5x1M8~}QHr7=hQt0Jg8Ltk>!Sg0H2?!Lh-mBowirTtxQKqV%`WxQ zp#?XDRHvoY{SV@iO4LUpNSuc$Fyw5i(QBmAHE>SOcX?6h;7c-V`6Zl;?gAazq_4V4 z^;Q4TdgB`ZDZz5M){*}ACHI1Px5{h#G+xezLNm4AB*52mFIo*>KPyaGMY-g|VfmFN zVTUhD?v^SwPMrc9$t?d~STaTGPsk{w;A9r0_9x{W3tH5Af->)&%Oa(X#gsx{P|^&g z$PX876VS6V7p7GiZPPgJCwT`+29r@SmmXs}&7H^pR2~FP$o%q4)=>m4=nlhCQU^P3 zLJ&I?Z`<#Dl^-TU9A4irWmj6<0bl%Avdf=k4En*1Rb_VEEKlfa7tEOyPux+uy5hS` z7}r=6>b|+!6xDL?!;%6w=(Hz9WlRo0Lb1YTH~o*V*HqX?yz>K>x8P7hSUiR@P&SsH z^(q4-yVPsp@ytPB#@=Te3Ttm?m!!=+jUCObwpr>+-Gt_}b}4nb%T;cBS>!g_Eoc*n$IwXn4>IasNkaNwtXy*D-@?kf z+|55nnKd4{-JC>LfrEi{`CWd-jk)AnF8L}iOj!E$;?-orp+v%<__kJKvzL58opn+^ z3cbmLu$%xy{8`+3_YVBRi%mWLEkF@OR{GdTKcetUG3Wr)|E)`R_%Y9q?b(X8l*xy* zuj03{UB2?F_(w*Gmv`JO^rD)JE_Y~qwC~gH#s&AP^+;MLRLSZ+kqv+fIXMj>NtxmA zGn6yx{qn6jP8J_T__3D9{>!1K$qB;Ye&muTZz$Q)osUUBIG4v>Rm){@txT^FRUI|jyl3T;vx-*y zE4bO%vU9$N?895h!;OhpeSZo%UO%B~y+S#2DdcaHM79|h$q9wi@7PKW<3WhjYBD{> zl{ZcPgl5JX3Q2Ti1gda-)^F|8VGdB$*`CVfDNP0Bp*P7|cN0bwZR#2; zS+$zAxbxkGQDM-R-3OGOOFwXuNxVhteKhlkq~73f)#VOGmBhmiJe~izP_3_7^KL)p zXaIuYZU4-~itk|+O#?U;inYa{QgfhL{6k2cNf8YUxC-a9oesz@bc%z)Juq=%#QKM` z`SDcp^Lm=C#~~%S0q|4|6KmDbTnUVuJ~jM^ds$=Rd&0%MAV`Y$NX0e<$;E97zGQ+o zI{c*(rN?SdR|?0-47c~&uY4tu7M9bo^-G>8dcZ0T>RC3m3}{wksx4yOn^M5G^|N;F za~_0@|93I4k( z=0YtiZdKAoCyozQ_mvC{__Y#=5ICra+jLo-B@{YIg!ie`dv)(u8{423Z`t^scTLvh zP$%1yF-pLiArtMgj~2Y(w=ljsQAZ?8 ztGHXS;sh2ex3h<34&-#bk1XMbZQ(8TM!*NR{GaN*9GVd2X*dXOL8Sr8&ID@jjQbtA z8?j13P->ING(`3EI*hyJHP7>~6t|XSp@&a@m>)m1x^tiF?SWw7wtD3{je~3fG3c1q z_tFSl(RP;KWCoKf76paR$ zb3LF+M{#LdADB-tg=G=VVk|sR$FRP6E-Uplh&u<`V>&>CM{W(Ky?o#ld7v({sM)&e z>Es+1DF8aO3qwZB9=Z-885nJO7nsiJH`HJDX2vaYM-Wk<(7wNs_xn>pkP&qIO{L}f ztD#ZMU24B4J7fP5U3XQ!-*e{(B8J84s<+}9i0wJhG4pn`;-LZ85YnUGwz*UrHnT5h z2p!8_Z1B_Ck$X6VJTVGY`r)VDmB;nZy#?&~>rJ&*yF;b`$f$Zd|K(jLRb3FF0^>>& zqQpCd{L-Y(*|Y26M6C;QwL(2o?STSRG#pBfZ@pc}fTxPo3E!uyv~DQ)IogECgkXHj zN6&p`#FD_8fd27Qto5?Djra_^f=-^7*Ml*=wXl* z0RE^G{|1z^ckePapos{6bMq{Wuk700li?9kf36oW)a`w~0XOjPoaXc#?E?3%?{|_- zSn;;IVrA_3sES{hI$SnQ>gM*>tX~AoW3~(4dZsLH)H7;qABN~t;f~r@)ly*6lE2lP zg(_~A$j46%SHE-WI5(0qbeBwj?zwt1+AiLwK$gVZtW9&mPns)yQg@weqQ`--t7!y9 zn*mODZi|*Jw!h&KMKc&rsDit+TlzU3YGJb9pUg?e^eCI~2!a&6=wjdR=skNi->Nmi zS1Z_H?i-x#rA(SayVvFaE|6DiUMN2ripQj#vuClA`cznt-nh&t3c6pH`-ClXW#%Xy zh&jjc)Ky?u+8rc*j-6bLfyKU=+)Zkt7b}9ANvFsx;+g{p&ACq`Kh(L(w<<6vt#Pyy zj4sd}(nRf`5wBL^zj^L4>z~Gh}p6xkPr1$?;t$7PyV@*g%;n~>CXfCIPMvj6XM`YtZjray%w!C1# z0g12c1pQmSA@`C~M#FRd?&vd&<8A9qQI~k04fW;_uc>97!r#|F$ZG1we9{W-S)i>b z?0;rkfowrZuPJX(X5ztmWznxSO)~4>5VeH(`)k8$ai4ngziCcxnPr+R9Ol znxqTG1G%*@ad~?!V?@Vg4^EOx&6ALKxAe;=s7rbYf@tCGzeNUJ!G>&}P`Mwgi$=K^N3BY=1%Y3?Z6~ktz}Z7;l!&5p$(Vk zae+&hohYka%|y&&(M_+(W!JV0XOB_C4u&@L&`xr9&pZ=JNyA@q2I5MMQ|RaN)DWn~ zdUP1QcsCU`xsw!5{z*Dt_0HEQqYIu6Ld86g1o05Ty!UpUDkJsNoX)G-vA7_^Ff)N9 zqffSDcZ#A_=?Q1gC{-IW3Qicg_t{l3o&!iY+l0cCL1~ZspPCLtel=#yHa`w*S0eod zP9K6V9mW@36rH(DTJK;c8xuc#caP=n!Q~%!MLr;Rd8Lq0bXBdea!>2v7G4i?RdP&B z{R@OUvRYtGU>a?g*tOD<;`u{F z>i%bTN4Ja|QY&V6=-)qio1m$DAE#HDM}XF3Lk^Yu?r(3O$0!jFe`b&VC_nplvfiE(*9!h#NWvG8L+c4|79O60ed#9pJ)M==m@NFuh@QK z99d$V{g&t{g2#{{ucWQkQ={qzgL(^WUpmXw$PeF$f2b!f@5LbA6B0i4pgMIAfjFh= zl4vX51HTR#2!1-v?mD<9>pknL?^eo*^Y~`@?{f96C!L@!U4;@sRF%k7|k6yMaAp(+q#D7Jv~$qn6NaK#9pqZjF76-TL+ISo$3 zns$TDWF3u+pu5^OWlcRop#wqP)$HQC3{is2p}>-6mqu*%`!9j1>1}dmJsuec8G^wd z?~w{=ksJE56Bh^LPGFH1`S=OXQ#w5tDgvyJQcFZT!|9ZnR+xZUJG`~L`*yW##7)nc zL*6WNd`D?-2+n16jtKN%7?k*1{m`OXNMDmG9@6&V6P4c4He?+iD<<;yJIo7#BL``Ky>H-28o#gi2*H7P@?6t3h$UU5CF-rg}O=D`i(_ zu|>aKM?;gmF_UqIYlANt?$VT*xh?-ISK>`^iXs|v`!TXJi|+ojI;y^qY(1mlJfPgs z;TqPpv*To&&o^@+TVY>{T|=s^Z^;$XmsPO{O=Ur&0KC@UHrm>B`didLa7Fg0EE0Ou z$ZU1AL}(}5gy)W}@}=ra)K6r&c&;!&vH0(88w{_$BnYmOpO-4lWT+a%GXujWI({qL z7xxzPUB-R^-z{91@MOWm?3c91GHIX|@Q^Ms6x+$87}wZAQ*i3u*KO%<-BE3^yz z=U^((S4fq~KRJke&n8@81szZXa(Pw67Y?wfRZsY(-U{EH($O8KrFOT}$!E@4xwDjt zMnMX}gz4{9-~{n=bqDaesOleVflbT{v9I$FoMDbqYL9r`TPTG$)Crg41OsZXe)9*H zW!*fT+kJ0y9`Gm7v+av*aPKSHDtadbX$|O(K4aM@ScBmH}Y;y`B_Y%V+b;ygF#LH3Pr%_sLP$ zsVK!r?5nRvP^Wq(ptI<*=6=>@uxbM?t&dK$%yVqAG0Y9h@`n5O;>CBl9wBK?jzrgo z6{TexF&jDV#@bN(mQ|;a$h}>m)werwrjv)JmPdDKW0J9mbVnBF!7rk+Q9d&;S*fRC zr;9eW{T~Q3&mz8TY*KZ<@DF#u*iTW?XNWhy#c4WW9 z4_sDM?K-cxp8t}oF(WUxsT zz@z6l_5K___C>si`EntQ=Q$T$V|nR@kBye&|IyZ)4N+ zof?7r#>@a)SqlFf0je3gzngEXJtneZ;+p`JnL^7uCY* zD2eWAtm6OD!ILP0dR>2|`bOi?t<|5ObbgI!Wzbq^xamFrg299<7fHFyO)1+q2;WrL z=V64{tl=NOKffTL&QDzIowBDDCa-UNm5ZLC%^f9PO~ALgSgDQwX6FKDN(#MQGdtq# zd~uriOT2HB?c1O87sAxq54>**aAd^4SEzn6L7VSfjFTtcU3mE8LWTcBMj+4j`cD6l zF80=!p07A8WhrwCwO}6~`zp8qCe7v@*L5hi=?!lh%~ z9S!4_+u$p{(4*BP#MuDZ9k==hb^Cezu7*On*>o)mGf$uv_Egiverqa7^O8_w@#r|u zYR@H*Br#3wo2yLfPUr#J?ot*W0+ zNR@1(@yM?Icu%l^@OdAotv`P4xiWwFa-~7r`>|f|(&yvkbv{FSYwgu4Rz=V$ueTqT z?ZeeXSgG*RYG7dQCwK4;7ryl+_QrYCnyh)?#gCD=7>mnyG+~zLcGRao%8u^cgucKw zqqH`Ykq6;sZsA!bZKY6|vt0azFoN(Ih>xAiCpC=+{rH(&5aPLp3HZ5@L-R8wi&wVD zJ@XrU^3dW0BVJ7%)^}s=t9ueYnz=vR_NX5uBfKVA5pr{@lFKH}n-$p*gWn$BP+vSA zYNG|#8A)0x)Awe1*eiK$ZTAK0#O^&?OCA-z>6Y=g{1iH(G!s7=JR_GfZA8&9{1xM# zol?#NJnEBHD$BGI_XpvX%uPi=?8zQVR&yRhRE%crBvogNVPvm{-8R3zA>F()b5PxZ zjf=I|x&6ULpgKKd09+9MqRG7(Tx?D@s_T7Ds!V!t0hI#;N?3H9J`!bo)&H+N6lgS^ zw0iCm-jGmcGNu+5>}R;U_(i$MBSUE2)VKS~mn6g|GOp)V6)!oCLF`NUzUN7inBOmb)3KF)8pUkej2$jnd9ms5)!Ww=PWm%SmF5`A>s{ zviR*}Wy$R10B>}8W1_yt&5gbQ9Rs|Bh01yk&B+rdx=w!bhFe3ry*yrVT3WX|_)NP7 z81S>94l5`LOzpkY9}Sbk_d@lb(^=BTK6|t7f`$B!4r0;0UB|8U^n|OLG8wQSWmpXa zf*;FRTj=x_@5c4o++26*cQQ!|V!)62#l?FT$Sc`W7oe&g>ztk{>3raiUZh7l;p6(3 zsPh1GQueRZWTbRiOf+~>TDXCAoC=@@$Dxx9x2!U2!^tJ&g7txVN|M($i|U-71Snjz z=i%?gm(k5HDMoeYK|6sxT{=4-Qmyr!UP?Pb=VQt$EPx*=(bZZLW2-kTRdMG+mMKW? z`>d-k0qPlNfBcs#r}&FSk{f8qN^ZMON|r;W^Kbi)8erT3be~azyC&75j?XN67xD$X z79}tTmUNK;`V2q(Vp{yT+dL4dFJlKs-`R`Jfpp$kO6L?W&{jZIAXcDD**Cn;u1R>;)uoS)^i0fUzxMSIg3nXkkFkv zt-6F1oxf=3I#1mll)NLHh+euq_twSK}mn1i?<>Hhq(My z)bFHq?yF$_=ouv2oXYP-s`8MGg_ht#)eBsXzGgXX_0x%W>~k?r5_L2gzX7+-zS zf|S|*n6C=;>u08AA>vRkMDBdQZUmoH4 z#w}X&j8Fw>3Y_#693!*Q`SWhnYe&?tU<4UD56kKC=2VO!c&r&-FVJN7!MPY84~qRr zSGjN=@X>6i*UOkCgr!S=&ZD92EZd`!OkQLb>ynH9vC9T3g6<8ygE=-S51Riig(XWD zqxr=y@eXAUEwoEc+G1ao@2(#IOR`*>*~q&xhC10M=T>4T;gdT`gS>uu=4tVd&#GuRJFA$AC^QHcU$93yz6#!oX8TcooL-D~ z2|gyH_YP4%#Z+@q;WP9URo_^W9B=tTx}nD}KVp>TZOT!yJg=(k0hUfiIL!OOo6&{YaEVLn>tc<1!;89BpH*1G{ ziwJDgyk!W^{?~@F{z*wY>QrYxo1ffvCZC4Rb|wpm{j*)0oyn%5&hGo%JcBLc*bAS{ zbcCZda1eDm7p$R3Zmhj+rk10QjkluJJN5o*{;Xu0t=BI)Lw+Vwl5bdroqc=WrdhqC z{J9MSp8*Mt#!m4s3oHXGIgq1`5V#ce{>9z*uS#SiR=m8ceA@eR*)?6Gz~{pRq^km_)&S>kCAq`6IYzvqTJS7L8yc&Svl$qdQ0!Ug|bOB(9s z#t*tD*FSV|2&1qh_*jzs$DeQLKh>>uT~Qx0b;QmI59R%LiC0Ta4^sWa?#=%Jj`!tw diff --git a/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png b/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png deleted file mode 100644 index cad11fa50410302df5c5f75fd4debbb464a4bbdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36256 zcmeFZdpwkD`!+76kUYADZ{E1CJ`2)?2SD$2o(|SB&D(&8f|7mXw^_i zWs^;|HBv}qFHN-~72!Sa88kg>J#WH(rq|-cb@HZO z`I`HUk{TdIzGW9TCKc1~Fw|2&q2jc$ zKHlwN?B1oax5edzVwWa(y$qIeZ2PR&vM2TN?~^UNBb!15YPQG&D5ZWoS6}K=2rp^n}vg zmHMJFk8Yhg9dUMB%pccpcRm+&dYdh>C`Q6s#nv^^-p<6y=+5Sc$Na7?y&EI_O<-K0 zHRIHZ6|{ivz^zJOGt||a?{h#fXYh^3-9~|u&9Qq^Li9c!uNwV+ z+I8%P^MlmE=`DBvqQ+gfuw2oHuX z33WBY=pM1ANAIX#Wx%IYfV!XQ-4EO528_6psPg~VV`rDmov^~ z?Y6A~TMp^K1$@Ad~%dl$Aq5LX<+(mHYzTl~r*#oU)3V zvYMJAd_s{F>PvP!r07eMMJ~a)#_z5q=Rl%Ands*$ja<{w$uEekw{|UjUwZETeEdyK z=P&O|njHlg59LFS{>rLKD#|`S%G^IeB5x0di_G58KmG)1A1otfb61jIP@uEx_Fz|E zvMl!{T%6~B-ajbNoBcR0&dRRdu0HTl68u)xUw-AzU8ZL9KY2 z5w4@@h|^ZbsW~aSs%p6@YG|sfE8?7-v=m*n)wLZpur8V^YFb>E*b_*ES?TD_eOJh( zT;Ng~8qTV2+BjE5M^!f!MGa>cxEnXDtD=juvxb(7lRErag?%X(XPq5>fj*A#I*C4x z?yk!IzV7UAAOo(0H`}GRR!xcXQTA8NydBAI@B?~lO^Cihhkkx^AJNCvg6xQlrm7ZJ zO$Ce7P*qb?)xc_K|NM}ZYaj_`B63Yt6(w~I_IHriq5}^D1M7&)Q@8;8>+mc(hJmh* zWWT_DetzD1Ympm}Mt*Ys+orIbTpY=czdMp$;i4*P>N+YKI%=x>RJCmt2dFol%%EEi$cfInezxF z$6!|%_I<*&INx&iaP)O|1@xGmu5(`}{=-yoQP;w%YdB*SRn>9MiW*vKI7LS%HLRku znx>9fy&|J4ZmS0DbL+4cY1a0&b@r(Au31%*JdLL2m& z9Le4FJAU7x_V=B`Ru&_ztN^9t7X6#oMMXrG8yF2#G~EAcyQzTJ{N%n&0VBtg8x}8H z-k8K}$4wsj`o`FrzyAA=@Kx5ghkWjM_wJ0?VKG{2y|LK!=|F}GGw0!zHx0ufwD7hT ziIfe45~sAfzACCL)BO6^Hu)6v)UI0ck1H2Vs;8+gI#IP6GYaIV$Epm%G`EIqR#^1QQ2 zu7A^k$`*l-(kZ^*q|xm~y91inRpBQG6tN;v?2>79!a&8sm7}o7(5-# zemGgV>5LYpWqS}XNn(C+!gvIl_Z^; z+S4L1f06N5q~9j;DFc7<9aI@Kiz*RvP}y2#7Q6u!&}`ThvV-OmE`jR2iuD-W(7(NU z>~#Lqnz5BtcseqG`1%9PY8ByAjwbe};6Z;BzzZFCv9c0F7lqM#P)F>HC?jUr@=O>t z6AviQ22uLv|2uB>Pg*r`$|RnZcGD=1$yu*N{| z;^0%SX|`uZIUv=2MP#;^?GW{1#OqcpI@K-{E7!k$=KCtq;F4glcx+|zCXfa6=mmE08Cp8QtHkXk)?GPyZ0$D{G5`- zrFQmHTN@NiXicg~EUdE5cxm)4vRCp#Vnk?LV`>&nuAqGP#d6}kM(%aFr-7L92T!b$ zNs`!dX%lgsmOXHd^1bj%%g-xO;Q};eyNBz%`7KalRQgH+&bxO^V)tz(tS7y(WoRhodK$TW<60%WZ!6c1ym_ z$xsJNwC9!R4dQa>cD^F-mv*MPhH8pCx2dXqNqU}87fetMSsCba@df|8N3tU+C;jKoJGSA+Ea*L{JiTm(lE1%$HF(^GDeFw64Cm+ru zksG$WEZ5b<$i?`OV)cF4>W9ev;_ z-XHqnsm2q$Uxy0%-FtYv-_=CIid5&;VBTJ)l@1+VgLGlSHlzDrbpxq2-~{dhn#whF{!^ywmR>+nMTEb1}z^&`hc zh;5nvw(`ZWM57jFiOo-amOQp$rh_uj!*{SR*<3+mWyJZvR0~z%{q;CeD^4z-m7>X7Q=r8w|BkOc&Ws5^@6E*N&MZZ+1cz?Rivx$Q4Cu^R-A~d8d6?Uw@3TU4Z_jasJi-f|Lj( z7O%fuQ%<(kP+R7f{C6dLHt)qS!q4|mv&7<}^9&y{_q(sUK>RaksfEIpV=k#M!vq7- z*V8S~*JJMVXmrrNB$N{ym13Az(`YbR!YpCyBYojbuu$mRlHabo$7W{zn;LvS)j_J! zwM3j!7o056ExDc^WZgTM5f=dPzYFJJgRJ;v}9uuzDbyR~ra+2@23{wY%esh?LR zxwl0iT0GFKuKs;O57j3;+4k+_r9IU2G|{lPEdo~@F=S7eeK0R(oRX7zVpW6IHy&gB z^>Tj1meY&QG41a2jGfQ0?#B-8d?CP+obj1ZU&l14+ge$#g4RhCyka=i|J+W0X#J{h zPiq+cMoFw&Ibuh(*6*NYnQrUTx2?|RnzmsFOa{&sQawsG^?p^0}WYC!QBN&h)MSllx*R!?|JDh9JSm zt@ec6W|(X~g{3_?Pw|SRW*gXcMM&2Ne?shuS~CA=I>AYe6>eOsaqRm{*8HU&R1uZ- z^Gx8Ha#pOpXzRFo^ufCzHlOH2M-@ROplnnlRl`{k1^7{3XoKLl50h&5OO9j5v&z? zfIF3CS$T9-swnHkKZWU$kv<5UQ2@-5kc3N0gZj2oNvtheFGO!x{c*}1%W7@AitNL! zx#UsyTRr=jsALOR3wWJxcoO z)bsagf4;nEvJ=)62WtV$E(nSeMr>*R?F?)~qM*K+Kg%my3>JbVfGu3vE9&Lq9%^H# zNDuYxt?!F7lj3G{k28bil33AHFB4zRs8@Fd#hT`DqY*eJS0qvz}&n!qQnZpx@5pW;!FlOpvI0Fuig9avQrD-%m`W z6ge%pIcTX!HmffvS9)4d{(lFudeZcwgZ~y}bznPhzst9qPnayt0v=n3j$x`GiwT9W zo!OCUxA0G$S%72JibqmxGi$${i97cvkam3b)m1<%g7eHV8m9u9-ykG5Fyp+(=#&Vp zUG10+lZ3v0HV`XpAtqJc60uRW>*rcp{V09>Hf6j;Q2&L1rNY>q=+!D}!f&vH2`0+@ z)N$O+H9gea69cF!yx+={9oj+C4TZxF2dFbImXs#pv$@zjPlX+LKm2liW{>E)e4br4c_X zvmiT(ZTM~WQN3`iUC2YeWq8K4n)2S6w#YR@zv~YzEAsxjaXk|c8-)dIxMvbHk02Db zxZM<&U}fs)88Z)t;bTSodRV&N+rQ%~fJRt#M0E{=lxUzlEOt72E7UA477Ly0Nc(5O!P* zagngv#Shigd~nZQbw@m7u9%)WPxNFi#U-@w_o6Jq#Z2PT>3ZU`>B;w)@MnpL!WLgcNQkanDBuda1I--tL_yNC9=2o|tSh`Njz= zJUU}BPk{9n(4{m6+`p|8)ZaE3YDvgdITz~iMQ1GLJn`GI(W}l{$5ghFKMA^CEw4&^)=ajQM`sI% z!+KQ1AUX~8YY7CH9e9R|yhR?(be@A&;rrf$&yfWmF9&2r4cUIBDu`L8Yfdn;kr%AJ z@!8|E70Wlz z0IS%nPY565J2(`zjDqWL5ing~j_+82^H|wK&5=OaTjVwu4yRqaHPe%PDGz}*d|7K?E_RzIB>fkf$|=#ht9&kE~R~f`n$W}Emp4Yjy=}vA%19m z?+p4aL#o+Tx8@jQT+;%!5e2|zhh=jsJQbU}cl2sm%GS@kW(5YYtQ0maMwirn;se863&SdI_M&F%u3-1cX&6zrO8=`~0+px=r)Xoo^XC_w$T5I7h)d+lqQ|CO*)t ziC~BJ>@Wj;e^L!r9l(roP?T%h+kd6Q5({aUfS@RMdTD)ye!Cydt?7 z?j3(^B;n&&Yg?>~t#>CBzI&uo24KBJS;v+W#bscEnOV(Wsz+lLmi5~TZbVIl_wuc5Sd^L2||7E`Ho5*mb%;Yy-O~6yn5$?fOgq?Ov&PZ0o0W#ff?56sW)!TELPnahDA^CRuHK`^{sVIsB{s9 z5<#C6qB~AUv>SfkuNsgVL%D1#PH(Fs68Y)4CpBfxGvrnGk=oP%)!>|AU5dtIG!7H< z0H#ygGBGWM#4fcEQPhon&Ba!xm(CA6I~0bu52xR_b$SD?z@UD2QUwuc(Cn7PIxij> zP~W9+lMSf_<$W+8bhnygL`iKQ{t;8pg|&UyrH;^5c+Jb{ZCcNyW>*lYZe4~;Z}V-w zgI}+*$y|`Wvd-Vg>Ic2SJN7Tt`gC)hJYz1&_35X6Y;Bvvt$Y5qpA0B&tLS_g$C;80 z9wB?PD0O-n<_3_~I1{GsRzmJ=p@3#fsI>MD#q?5-kS=~XfVuk|ZYTWITgI=9l|y#W zbWQpD6H#6VqJKmw(m{z>R*h$_JemI*Q92gq3qwU1`b?Fh4*V5y!ar51qpg}a$5pFv z33IEchf#e+e9vW~A)=!I>+M`wxg|d}A-cWrdG0ZcXkc4j5mMpCA9Zqhr;MNsz)rkO z7f~q?1j?T%4=V_lyFl>qP0yI|7XsEmQQ0zwT+UkD%^3CgfaU<9(zt_aXG=3(CPb51 z87G-4%Im)B=grL3dRW$98xscoZNDwSvC6~|P3N1c!(LE11P$`UE?_jw4!X1#8wWHG zD@K4CfDqMMa@I<7{D=+h*6C$HM?}L>3T!FDp6Ttsd0~L zzDaZhH%MLIRQxs?q{B^5@QKD3>w@)+FMjFj+Y;-bqR_dd(N!1p%yUc?%V#nsQz6Sn z)8_~8Yz=W~5znF*0v#$%IZ>W5KQT2vIyGNJUH7vghViBO`-03QsR&?PmK7O zf6Q3?dE(@T7>DNyqNpRW0nJ%BVD&tAr_VS#oL*JcatSV2rIHfwq}K1>PMW=y@OnP! zY^$08Gn3rgQa#k^jxl#58sF*Y#j2drf0IYj0Iz{^+hc3~QVcTHQ$8dwlV!GSh-J2# zY-b~M5rqj;Iw18dOXFp8UG!!i5a0cF;FNWoFk@l^x(K4;(Cvnyc@JV?(Xf8-{K?vV z8kF8NupW}%T=Rzsk&0XKlg>08C4@)v9UM`jS>4|r&`dDNRVlbSXqh`-^Pl*Ym#Ui3 zru^hnHmJ;O`zeH8%O{`l?O_y)M_#ve1StbQ3Zy9CPy{7>Ak_sb9q z_!KV_G@{wC)pp>c^RHghbD2^Hwxx*Yy~Dm-*zfb>9mp49W9{4q6m+)C|2uN#zdy)b zsT?K<-j8FxLkfMlvJ~dQc#zNdcF=~U)>Psvlu;IML_<6k)Ht zcGIq52o-kJ`MCrX@*$t}#`~VSRd@?1bx!qChlMJweaknBsF!F-poH-bWT7i5eo&`; zdt`|9u?7jKm%-+uIzK@Z9`O>zsv2RTlLMDBtP6aXu}Nq_)^>qX@*_G{Zs3l6ih=U9 zx#t=K<-B6=mo-cJ8dUpq8)EiAm6BhNi)H3sn$&_yP$_P-ku5 z0s_#J4kvs$u&sa3z{ia+Mb`qlRDtWlF}t!#fb3pGP!!6;@19Wb&cDG*Vvr&-F+Vv! z0KFvruJjZ9T?|TC6f{oQoS6Hk)nu9RnxjzNH6#ewHmSKArQ91x@URD?PNQAlkBAfuHaxC;+#uV$C}XGv{&z3f^)mI)i2 zep%bBo`KA-v(>~1=LHgFP?J<0a5)_3Q!;=y7wi0l$1q5jdZ@LaVU%R72TWM@nb#63 zeV7wa9D_449w15E^b|PPJR@a~TaMoX{%o zE?lhxdie0afafWw(Tj}UGTzwCPx({KWXgDlpgtkj|D=UN<(4OHGds|mP+zj6X;yM* zN&6Gnq1?N|wTxHAC$hvc^wolfT}w_}>Ar*mb^W&}kX2o|s_JWj?S4vZjF}h!r8x^< zgJ-4f6)JtuZC6f4zLZgEc}*^$QH(UK%PW!JFAU+3q%v&>Jk` zXN#A2vQLU;T5H2#7Fme*1ZteKQ4n#9c&Tc22cLL<~4C9*M5Od-$&kG{r?d@Vex_03jmBb zY*6g>Vm&T=zUqAG^VMtl7v8{d&6`pKhSjkA8oz^ls2nApU~*R{0o)o_uG-S8W!;v$ z0pD)$b*r=Wr}snKcj9hhTBg+Fz>=WPowHHG^(?cCDaEp&N_(?lQUyjKp$5t8((a%1j zx#)n$ThI4YCLr|)WpU^Y3)lrm)#wG`H`B*o3O@FHr`PZ7U5)kd1an{)+A|&DpVDr@ z4FS!~nzGEAl<$jenC?HiWGJz?gn_el!Ar}DBQ&A!gdpjkbzBc)0@q-XGTqN>)|At( zosYb~L^F^UF8TU+o;OhL{3y>hS!m5?9`)&?uZQ;Ev7cIAljd9#k}7ybO-OvdmXJP!VjkUj^fzrctPKAHDf_Rh* zncvy)fC}sxvkOMu4{f1v_!{u`edu@Np~q^;yrZArkw7?wid7`+FG!DA=jU{Sz>fe| z2)(6Pc~*7+nTHLiN|p$s2HC1D?xp_BFvRpwn=YH2JwGf8&gX4?n|Z->IXGUgytr;g z@(Iy?^#Eh0fP<@Nz}p2O_~PdW*)v*GD=!d_nJWmbpe)}(Q!o021FxT`1V*p$VMvJl zHgw1Sw+qAZ^2i%E^QZsig98#lHJBR)S6hW(-_*6ZuQ+-(==7OUJ) zfQ-8hYO1V8quBWaxYzj1q&&@ew||kVC3#z<%`j5nyh#sW58FUI-$9|#2b<_t$&XtE zaOhOHC&1>h#US;FpuUKuFRaQ5HC6#=Yho5=x(yW1Yt*kms4`@6SpJ1E$aqBK%@$CfwRyoPGjyI$bXb4te z@2+zL4xAKb=70uU_Vg=Y7~@%w`M&7;|@5ts%(U{crOb4L+{w6Z=Ahn6gMbCJZ6K7*WJH6v9{gmHF& z4QLgm(D!M=$9<^hTzf{&73SvJu^F~@cw}^UQ;w}R?5mS+@K(itBc~S@0xD0{1wYa` z_5c@qm|pZr9u(P2IPsqaO0KGUsiz=abE<0|OK~@M(yx5pIQlg)f-Qptna!8>?K77z z5Th&HNBTWT?ArUiLbC3J=-&@ky=D0w_Scz zU&C;2_;K8&xJ4*4DZ}+?wp@S4`6GCb0|v4{j|Ia|5X*8 z%1KkXgo#a**JzLl5D2=XWmhZ=wioqlU3!`V}0IjFxk(bsLXmnUb-fl?sERnVIvRhYiSE*9ESyc7v4An5rDq_IbPK0T$tese|yyl%NUpMo<*1s_KU17^NY z12O%qub} zD{Q|?yIJ`YKAxE9ptig6mev+6lHYCzw&zV8Cox z%YN+)p2IHc9iNdRg!$w6;W!Bt%l2}#?I0DAd+$D%Dr>n^1;LhRzh-in?+=pxyv8j>K;9-Ggbne-Dd*$Kpx znc@U4a%{`fid&d${O6MTF0n8578s3Pq0fHnqYew7j@}`ImXyPy5-#~}4Xm%xxJY^k zG0D1azIDv^LNUyEUO<>xU>dlb!uDSnJmj5-B%LhzyU^%FmmshU^<%1c9YN3z+~o0} z02575IWoKNk!oF70~+Dtksy9n;+H~pkIg~_d#PRx7saKlfxiQwa^z3op%j7kUufv= z`R!A0D?R9sMq;57PtGi4Zmohi;F0aHBsUy}O4(b<;ANbNtUF=#9aGNSslq11M^L1>f*MPM^}DWAobua zu=iXSB@*8YuV+ppK{;@9*Tyg*FFF5^rA~J}8u~5L2ACBtlX{Tp#*1@WE_*bviK z=hr?m%!)iS1y+Y7A{V_Z&OzkTyXfNIL!bQ#0@tVn>IsKdLTMWZgof8b45aTfjt_sD zui#=qPS>>zJnY7;ea@SIR|CBTqro;`>kmV%{(C@kt05||O{uh3K~?Rn@4GwcKFm^= z>^Q_I4UvXMCiH4o`LsYh~eG%j2e0iC5wy9))H zO`mS*-_*@h%++fxAUVt7l)Y3xhRroWx5J=U7(AF7{O*#4|1e;}>T}#$Njzu5! zT$-}No(_x(A}k!uppxjDk~DMO)w32c`!KL+6^F)T&7hqy#VcIe&q4e)`)7F0lrqkr(tev~GebYa8mjsjgc3tY z`bH6eD{P9WsRdb7RIDZEU?Yn} z(;ok@-@CNm?+GN(&csWjO#L8>Apk3^?7g8?@%I`?)0uH9^k}3)R~IGd3P5OcZb7hT zRQM|sy+=Hh9;_T~@oyCmroS2>w(7xpZ;4)NB1J&55`X0DE{K8^DDjtSSR*C6F4#l= zB65z^LcS@&?cHB|)I{L{X>=HDZe#K1dZ_!sXZNB#7`hK}E7cO)YvmE*sAg%S8b zA@lu7DCf-p)?c{XURg~GKV_aADG7v6&Vu70PY)F$S|ACNe)|_nSpq2qDSB=iI9N|G zM!#6@rYy#ZCdMvEY7NmKHTo15?MEq4Cs)XT4ccW>xX4EEfFJ!W19s*8*r7mmm=&OPR>HD8IZr43qAc+9WT4%753B3Z&`=wnG|lRdiY@uJ zEVdqa_GlE=L%wt)s-Pb4S1-SgnbVDXf^V>{`RD*!!eya;{nWc7ClKF|Gcmr_Uxb-4 z(6;jX(y^XrNCs@Y;XbCNc;$KIS#?j@7}3BU5)A`oYUCs%?k%H33(^-6duJDoT_rIT zdCoLuq1T7awmdX-$PnCTX9(Dtm1U4CUk&==YS@i0eOTmXy6tpk5>(<4#=l6CR*apq z+S#+yOQKY?VLD<$UL#ZuYm(dCcH(ecjL{CHqK|zDX{IZP#|AR-{-shit|epf`IS(S zWg)rpuNSh^kO1EZSghRpWOrrP)ZmpUh~vYcQ$|HMLb!beQkCcHc#zY3s7Rd6!n5;4 zvvCpu zIAX>il7;T0ibD;i_36@iVjn-bEw84Afru$trZY%lU7m8;VJ@Ks?=RPpGM>cJ*rSNx zo~mBHXXhNVd=k*S-H4`X!k63reSuSl7?i#7%-t7lGcDpU`dCFj=~)sma7=S51DQ!L zhF@!JQ-qliB1#EbBU^KN#iqJ?hS9d^7jKo zdZ_z?KRk|Y$(MwYb`N|@+Ch73Oj3kdU2jxSz6MBTd{OkPaSpSD9f%0xjt$gbs%Nsq zoE%Qi&2Hq;j#0*qp~t44*Pn+Ze;HEN|0RZkj91{onN%HgXCy5Q>D(h3Gd%~G;VuiX zPr#!SUK<@U8mLL6G>C((pLzjGr5Fj|YH$DZ3E!6nujFl*bu2$&b`#)lGb_}h+T zj-FB033Ebt{pvtW%g%siMKIVkEgZ;pH$cs>yN>nff>fJ$^$*P{{#XZ4tR2`WQ_r^> zWQxW)i`=e>C?!EK_L+IN=ZT=;<4qgxpS~;nUBXzDEjgTyj)hof!}g(6m=g81Km`qF zLip({pdUf(@?4;O6XbEeF1x{&oI^umE1@|e8MO6NQ&l1D*V($fmM@!YCPv$8y~z6t zylk%ndGkF#6}2y2I)Kajb5%ZK@c&3MHxP2}rWgLu1v^ri$AZ$`3P#B=A8>O-F$KJ8 z#;cBf=4G=?3s^kO3h8G=2s!Hqq25(w^TO9#`2tddye3u&(E0M}{R)_=c57ZQsl?|C zU$+{f@Ijf-nCb@^g_Uv9ACfOX6Uy)(>f2<-?z}cB^1f$cBL<;&$D#GHL?a<*hne{q z(%!pA`8YGx>(S)ZYjxt0pr4o)7zj#TgfszWYEeM5c^GK2kmIhR7^syBWT>k_v6i3E zRobS%+CX_F+$dzNf6}!|qHv#vi7xBwxi^r*hs-G}Euj8PJknxt9`sGNA?gO4Bty|` zXgk<-vd`6GnZ6x>H9xjC zj3S7Ho&$0YSPGfYe;}eS2_*EmFeQd9lMNf(#~I1hHvdSwm%5q_w@ z`Iudy@yBmaa$ehyWB|#EX`5u3eL(WL=qSlT*OcPc36Pq8<(wBAQZb|38n(`n(m=C& zx*n-8ZY_l#)sqN?AB3Y+e2_*8Pi6!(z6H&`MN(a* zs)9X|9J1O8PAZbhMdHV!+4&G4M>|bJjOhOmSvj{SshyrSK;JI-cq~5ZE@VPM-`oVH z<6@POM-ng(qB4_IK}Y`uSL+39XA5KG5}I)ipT86^Ul;Oh2B#bky>rkeErip6GXwRi7+5&{6lI9HEhD#~%ZVFSMRKzC-f4yQ0`wbN2qfkc znkd+Y!cd2=2bd+Ev)I}rR%PvQ&^xV{3gO9-4UHfSK(ExK{5(yicNwbXk`Et0On3c7 zoyZBXU7B8efJ}bIE3!{vOCR>;Idg^Ayf~!kDh?WnAh31<@uu>JPpFKQ1Jwfc23#%B zJLl;sl*an75N8JaGx!dQEw_8p7JE2u{qEdmYcd)f(hJe;TH={V@1^?2rQZolr@%cb z%z?@>puJcg+?AYvNrZw@rcKwYnZwdnUAEl07o$tz2WsCxE{x1NC=eS( zA-c~SI>>*4s5H+|TVDu}koT&+u@p(bhF)e~Hf^}dcQ8yC#LV#I?k%5Gm1^+0B#;2W zSBXIb;Sx_aarTo-fi4(RCkLYs`;YtED(*rwK%Z3b`mhZ<2p!^dn?l+nn8w{tx0vbX z)~+v0*T?))%r92GD^!IKG1*mMz~QMb#qA(_z5&^jpZt#o(&~sXRf<;WH#2seAF7E| zXv=_%4qB1}tV{3El%Yb>9uU1SZ21XejxW(TAoD2v?y)V$9n`=Cg&-4m^(k3HOb%|Z zS?KmIim&@Rq{$l)UojvqIvyT3jV(-YbWbSwmL{0&N@B@Tf;$$2ZmfKo`923~G)PAX z!WP0}7>dxvR;D05O;8RGn@}I~=LL!PQC)F2?bS=6JYNm-1KQrQ@FoXa$F2bZ>-Xna z@hiYS;9gXD=>wQ8eS1D+iAUbc-Jje&F0;m2=E7b?Iu$;0EV-?%UbBjWu$gv+9c(A* z9+aVl!?s#%Ckf)K4$vLJy{HI3IxD98AbnqxB~}H4#>Q6zHMX+*o=OC=W8SeEnqnJO zk2|O&v7$J1b&;}kLHXtwCPkKO8j04 zZaU!kQ1g#1GgSU1br0d=2BPqxXMYW^_%G0PCv?%>F?`Etq0InjjBpM8DdxKYMl6Gw z;f;9nFwG(0E(Y@fUq7Qk$D)h*YZ>-y5xU@uxCB#dJVj{^|Qnl!>gNO zUTS0B3`&*>B0Fr5!UvFW;3lZ_#x8(4rFF{21cJflc0zY)5#aIJ&CUssCGMV`Sh=+p zQ1+~6Ora@4UpZ~SlL{W&?=H0&9+0h$Gcm*s3ij!)w=azLPpw^y1RA*0O)y&NgTg<& zCN}=TLkPf#T0|dOkFtbLj7_MZhDr#98DLsMwrvL+sg*A?Y2e*hxDoAA{JDgWj5(Qw zJ@mP6hK2kSWdW7pPk-rJ{_M_3U|L{d|1yt*EOLOCun@rJ<^9%v4LhE3E=J=1}fdhyN>DkGCVHY ze3J(^wyvE_07(=V<-%pnv+Ga}Hw+4FzmsLd;4#V9wD64aI*V@9T+%cYu22CghOs1b z*wI1uhq523|C`16>GsL9l(n1)IrnKqOp_}xK+}VKqp-Piy(}M+&+n`qd$uAn5qbnW z+%Th8d+#GvY1=RoIZzd>WsD2;P#azh{{+_^Zy8QuTs9XhrX#;70aoU@w{Jm&15h?gRuxXMOqkj3AV30W6Kd?tr7Tb9Z?*@7(4X6%ElTICTI! zbQXqRT{o?$3Ol3?!ABi^vG5Eg2U*A&)MWhwNiv@s;+-A1tpEYo!gYwh7~}-o zgkXK@QH-JTatXq!azsOCXIse2Du_q8a9;DY!{>8d1h2k1R5eIEqmR)bLz%GohSjS; zm{&R_(~x>XGRemsv<|!Ophu0O^+cREPOpc26@Q?Z}TSz6-@87y8MS znL1`zvoe3=Cc|t8JdEJW^%$2?=sesfVW2#RswJ^5slr@R07tO;Ll+8)rYn8bB; zOak-7AjYK%^&UjI5UtP>Pm16ZX0uhOgxo(u^&d2$w~o2HdMC6paS7s}8uPFi8$qtb zBQNvQ?CAm_a_H1{pwq8n0L9im!B487B@qcgB-R1Mp{jWe0ALW5#zM}X1|mSmo>6t* zdW7|w`04*0R`E(5F?ZB)kUJPcnwad7nXv*Ye{$zVILH7b&miSrYBXR!?TJU|!ZvJM z=F5jlRpfwyYmgQToVwQz0?2N@+*wssU#TO-z-0|A9GDcEQ>GH^&Oo-V8jxB-w}kqU z;dc@tVy+fQb?e|Y&?4X!caFUjKg1zK?q~WQTw;*cmGrT>P*Xg&VZn&mR1-%e2pzF5 z2xx_BGZjXUvlz6+i$Zu+?|K0*XCE|h*zF;{elxiDK8fw1oD9;C0DeIcbV-M#+}YFv zTT4NH|E{TtZ zS%(<(5E4VOuq6!-8p@{&bOko4%6KG%~ z99b1IH&AG7OWq_4V$81`b&pDg1mR3ccgV+uN{C*Fr9)xpAU1mS8nRosqHe%G^k3j< z=ciF$bAK?j=_wTG&jXFBwLzi^f587&ate3Rw*}eA@67JeMc31<5S@OD?!V$GVrk|4 z3z|-d1Nogp(?0)pFHWiW@*bF4P_Bu_Fg@+ps6g|wxYQaq4x@ws?2o9@AUU+HH^P+O z*+c2?61L(25^&Liqb%;Q$*Hq6<#PL27ar?lKaVf)5^YP>Kcoh?L+lX3oa0_k;8Nxo7D_Vup7}G(!qveBIVtQ zWp0(Pf5G}&-^FaY;i{pd8!-5m3lDKAk}x`SsdY_ZV+&5Pi`Ce4#efR(^G1xVNUKV_X-9|yM|3nzV>;yZ|&ShD;z z;_!nJ>kr#a5ISrt2~L+e8r@u%2}j)ALMsINUJ^T+ex;!Zcbi$fvB2(jO|u_5+F&l)DA6p%RnNm1CE5rxyZza z5yw5u3V)mVpRcm(w&;iIuw%wL>RCYbhvA1>oa-{dK1qU{JOr zc@mYwJ3<{^;6AfMJlWB{Pw_r!Fw2}>bYe1-ruh&**JcV)-Z5^=6#>!APH+9P zpn{ke@$9dV*^`c*1=T3O+7Fvl?qrc+)*mzf8y$t9%;(1Q*oHfE&6N#$EQTTw z8sF3_@Z!_$cl#sfcYT*&$G9K`-XR1vNi1@_(U2=NcAygxiFpIE@P zmUfq?x*Hvg`mDtxpIFt5W|u{2M}uWvfVdQ4=Z6*`5d(_}*;WAR$oP9b zXaOZWf)4HBZ~Wg0b;!ls={!9QqzSFzF6DAHgSiEYKnUZr^TQ!mb)`|cDr&5MfY7f! zn(L8K3*ZFs=Rr!cn%MECwm+Y(sE0-OQ1`+SYo{BbJ0sYXf1iR77ZsgstYHK}3zK)+ zxUfZRa~=r)emOI5S&uKGjH4@v8R0kX?kdyln)HO`jGRvR zj|B$R8Hx4aLQq|BBk~OnNK=9x9~vC}nz5K9LeO0#Wu#z$_1XE8i7;XdnA7`ok<()M z+`HmPh!QmxgBbjWjcduLArZ=2dEM%jUHUXg(9a((micK&-C!UX^P-6Juz7xT)?REb zgzcep1=AUXV6^8;@-67h2rF922Cavo@mcsvh}=UqDiX}L?5hBxs`Vl5=iV|Z=Q8&Y zLMlxU?tXH7n#rc30nK{gNl~#L-BlFGyO)tpMrSl3jRfA^tKc*O!xMpRzSUo&AwFu1 zkR`FBDwakxV+gzJ!A84;Yd4eB3V`Uw)Ka8+g}UB9H{oBAv;%r5pO61`Gw*OsFQDAM^oDTFYa8wZsg6k`L2}Ct$;d?hB%_qiS7nFw+kuPPzC;PpOMgWy zZMIMfm0)a>9J{#3-8_P}jlvGmK+zg@VB7l=VZ#TYg$cO&oWCE=?eD|3OiDd*fc&FN z@Ubn@$PH#45>^1Q1HxWSH5z9A@er=cX;$ zp5oR)o}ZU54hlnmiYAwi0vX}Cx zzOK)ia-!6J#Ddaye;HB7pLe8gADlvY8j;%a8+kZP6ufDq?JvGo6dZxX;m5Fi5;+*f zLMo%s5CKtw;6VCvKld;Bf{)ql5QhMbNt8iL;G_tK;?DtwYEG?wf=BB4bxgKOI+%S= zYE0WR2Bs4U4kl?~pM*j*s+@l84`4k9)X+cOI$|?b$LlXLsx4;$t0sH^$$GpHu>VNy z5DAk{JrWna+Hh$7AjMD(#4^EIB>kLVvJ_6)d(SQPs1WXU=MSePK)3z`w3KwkLC8JL zu|DJ}>{T->?$91xu|4i$8$P}oI+?iqC$8y;Sh#hW@_#UR`XI3quaBNU<}j*{Dz<|L zDJd}Tyx>GO$?6vAShoB0pAb}QU2A0yHvxt7t3tuf4@CFfk*dZUwxwYDboIuLxI^DL z*iVI~ld!7AYc`Fc3#c ziQu&swA;D5w3i@OKET*e4D=}cavBNSd_Utb?9Izp9qLffem2xW6I@o|xagy?$u@94 zU>3DMeL2V;QWLyy8G@r?7lw%{;Mp323p1ASh#$^#L3X~90t0C9LH6i3KJ=-q;M9#! zjdWw1atPp=aG@th67^nSfuH7BU^N}(*YH^-ti}|4uwtM6RZMT6x222=g5|s>)+Y4>b*dm*$0>rb~cY zIReHQ(nBX^(@{k(i>eq zpKPTokG5T4UpQF_2ac4pVfGDzATaWVLSlu#ZP+YOA{4mzyEB~f>2VL)Z?FHT z{c!|N%8)9%HC&B=YI*jD8u z8RnWy=ACO~kpMpGO?Gw~IW2sPS zS2&UamK@DZl>(uwS8KR7Iyh=Y%ggVyl`;<C_dnplz;T9s)bWO7Nbfr&fG8wx(TT#S+bI& zc{)6xADrw(m5CzMb7+hG#H@nXFaZ>8Y zoydkv97IN;edL7AaMjFp7agt+q}S@Fw0ks0u)=@srUxMxy3Gd1?VupOet0aPxe2-} ze#OTuD&iTL?1{!f^3HG+#KCZmZ3Jo;*8v5lEF>)Nst;!XD|H}yq(oKy;x2PF(|R!5 znpPlAJTwzZRu;#h}IA#`+j`ft`kzEmCz;>5`t)uAxS~fjC~FMfa&0W9?7NzdB`1u zpEMEYSw>g7%*<6Zn7-gA$p*?zJ5g)W8_YDQ4agmVP2(jM&(<@GDLKg)d59%iv?2Cp zjKYYr>4D-jzX`euu{S$9vYbrO;S7T(tGNIke}t3yFBdhnW(>fl!ghtsV+Pnhon+E` z{NzPVrCS~P5G8`g+CYQ7T@2cDzHZlNA|sLrM9Ewbo!6zUFN(sf)xgYj)|W?(i&mHU z9q{COj@DecQSy{+*0oobhK!Jw<6od8NGo5FB)lqc;Ub83=0y)!Jax?OkC(ZxDuG`(e|h9+K%l63DuSefA?_M#_qFX{`4^K&tXQMy7n!^V_5yvtZ0+$%=QsJG^pj&*dzhCc@$?#xYf|q@f5fMSyn=d3#2m6QZ z&IYTt3!Q7h_-i5gf78B{yz(Zt?hmKeX85YC7Q zBN`p+f4KxBJ1|;Zb?*~ROH^E%JST+c)!raKzeDmSqhV?M^gS&2vDY{>>8Hrgr(PKUaPb2{U}ld_cZ@YM!5{lOD8ZG*p;;ly) zU6p$U#x%kGlT_k~8_ij_Zr+3;_!#RUP(s@;Kh=QI?-f6^14KQ;c)7oW88(lae^_J) zEz1UbDbGHWKm!W4oVY;FOL$~$xXt{sF8IQ{|6W8}~(M-KX$Z=P9!eJ|76w6o!6 zEOsH~L=F~<=ia;`H_Hos@^7MNVoW;d!RDx!LnH&D+Y(Jzfv+L6zyWlGBBD)8F!lC) z!7IlmmGMMFA04XnUeFVGz{$$lQJsgX6YmHK=TVI30}PxD&J`4cJ5;0K1XkhJqvP+p(E&P%M|C&6;a& zfHcGPZdaZ`YB*J!QQNxId+1|(Gedp@d&zgTt(bS*G@m$LK`^Fhscv8Ohk1mk1hMq< zl%16Y`Zuu0TVl>mc(}sWY*5;dezJHO;08x*!eK2~d7I}q_&fP)S z-;e6ifT}mkq7sZyU)tx$iZl}W&J}iM9pF_FBqr4_{r0v9>J1{HF0zH9bEfFzGFP}5 zC-uaGQ>#UYP2P$(>`8>GheIq}U{HN^c@yE3IJwde~a4SCW zC5Iy~QbvbqNScClRuLs_+X9O`*Cmz)Op+-4Z-W{yS-g-#h?p99!EniN z%5}juguT)P`}uf)ChDUTUtC6?DlwGMBjO^=z-sKb20l%mIOTK}I#?(AcOnuk7-YDR z0};%7CxX$_jwkm;@h^$Fo`s?-0Rf4W<9CXh-e0ratw%cWnfZ#^xOTg9V(v|xWZWSo z3THd+FcwNcXQtxV^bc|OyiUCP7+9x!{Pi(>ulYQ0x#)mcuwUVW54}Gm2C!{&AUdZ( zsMWVdJa9*?4&Ade^n`rJg+uWA_2IwVCzJoJl0u*vc&-rZOGKxs)+lU_w zdOhkG{aPft#j4Q8NC!q}clPvwTs|)p?M_Me29Y zL*7o)wQ%EoY&Dw*lwE6}C=ZaTvj!D*2=QsYp9k;YW0iAS$~2S`{}67kgut}f6v9Bo zdQehhMI&Y3!>_Cbs~*pA8WNZsLjh%nP$Q8A5{8uDL|cf=aqe7+^%dzpIJm{%wmwa{ zKi1Q0kBU#DMVJ(0vmz=Orq*xAx3Xsw#%?ga>)woX)p~5)KL^||*NT7$&`c8nx2vc4 zUS}mC5`C0J48x$+YVh3zHFy=OQ)J-HMN7=>Bz65i!__ahsVC8=p8gg?J_%SA#-DCa z71N1Eg>}dOCDKZrMP9Fly6O9ca7n*Zjp3NY&2M}Q8=mdqqd{wHhuzh-`tg+ozNr)i z`1QzpeRcgtye6y9B^e}AAZapv`{}~E0y*Uk(H7P)_RHnVSCAzY{i{8&!f9h;1pkbU z5qvq>9pdxhVjgH}eji>RKP|nCu8t_H4?E@^q{AZWOGa*w!Sl=V8=_m-|0&8YL?}CJ zDiIsQU`gS2U$Toab6exQ% zXN*~2+_BElh(_?j^VOveV3Ed`={a5{_&JAxGmSpVG20|=_5Dt42~lfN1$mh@L6=`Q z##E0%^V@+?A$-R=N`DE*vBwL#CSzJ4%EBMkv~Pi^fSy{}T(&JO5R_!NV5uJLeD)4S zXb6#nB}KlYq{u%#M7hW&s{9X1`1&Db{|%|br_c(%K%1LlK9#4fQ-@!Glm-j=T06-Q z@&%}oL3EQi^fnox(cTv9{IQWt%30v>VQrr0DOu1Mpt~O1Y^?oCUdDeQeCz)$z{Op< zPi*XwtiB+;Yfo(b1pGqGlSs@3(%vI}E}@N?mBDiBiV!>7TTPrR7y0fs3Z*-QdCL-W zldDh>-!CEgOQ!OYnG*O;^91fcaB$U$!v7c@rgfX@!EDM8vMG>|Wr%$!Z4VMqkdHpz z`F>Q9^j?9%Gf4PPm!(butm=*2coL4Mj4GiL{&fO5>e7Y9fJi|DqnmjX5aXwhB%)b% zD^J=`Y}-K{ub z(ZgCA} zS=RjG!e)0W_sXsjnQPAK_I25G{AB~zRNG{R+km|VEnxM0HC|O?a5CfMM8YQf;m#2W znp&>{tL+M3KcB9~%W`ncp5HFha}(r**6VCOWclvJ+6(AXElXYF19bO7%Y2m88TdQexINvK zkxw+3vVR-b!d3!QVO2HuBr+ma_FGqyO{ZHF?%cbC7V+G8KCPa0-m<}DlbIkaJ|&tL zJIayl*;FKR5uRCQh=z*&?ww#1Y#H%>{oO2`a2lLWl0KZDbSg%tot>akO$m(AP{`3Q zUP4n0C@3peT=}#LzWrD4N|Dd$Dn&B0Ef%ggREr+vO@{E>yXPigV-wa)1*r_lym_C|Jh6d4b&AhQhBbZxGlw`ZD!95CTsg$_-r*IYOJ1zi;Web1SA|o7oQM z7OMD?t1q9}P@zW2_tI65Ij~{9=IP4v6OE=n(rS!;I6bsK;cIz;U@?^ondlzn>os(z z^>TCE{2O1NWcgM*zC#;x=$s0@AY|*F-PM$kLk`t;0#Ex%7G2gZ`7Am&*jt`nS)myI zP1LsH(^JdOHZbeJ^G|oTf^5{|({H->7ZJ^;M;>LC4XX!=mdx;9rPO4oX6cyE;x13n ztPf{K%Swr!*cd5uRl2QrR#Md0p1f1MdBt%BY&I%~KUW#%hQ7KA74K_$@E6S$8Ye&X zXX1eEiJ7oIYMW10t6*!+EZ56V;eBo|>P#NB-(Qfj)};ywaP+w|+-80m=$* zaFuvO#^ZS>3IY-tsu*Swx9FgUSTICpbiQBl&B*ZZ4$tUg)GU>)H^e_L2pZvEIIGSq zrokm!_?v4pDbooo%NBOg&jTaPMI$+nyDNXhL$VOqj7w&uN7N)T`lk+;bVLyl*hi?B z{(vFFqq8Nq$>Ns@HDm!~Vbh!(^S#z!IT0r*kPDA4>0P+-B3l>*ytEY$T6(#AbsKPCi2hI>s+UgJ|z6!%0l*?Z~)@(s@m z<%%KeW!}XTM{drK1wVv1J(*$UGeWzxT(JcQzt*zSorCR5v5Zbj8pk))FWJ`^!VAj= zf$LDn+G&mWJkK3#zqY0UTFx`S{}=bMU%En!*8ZWugE94fjS7{UU)`=xq1{;alHff@ zp7z^z>fV-9k1p%IEdI*|GH=Yfs_b1PH)$PSK8aeUG3opHubY0CM5Dl;sVg&}ihkm5 z^OU$vh6<5osyd(MDOlB#O($8rZM3o1vnYI{si&GI39*IAV?BGOXXphr?{`VAySl2G zcLAT%PrI8*68x@SvQ5W;)j#$4rnD_@C|+PIckW)@QaiVjT%~Djd6zuhEHW$o(SAN} z0s3?SZi+YB|~|7ZAR``23E zyRhr_6-lfeFG{sCLKRl@xOZ|}dL^6=u0tVF%y*sP|8}|}q(Kcty6@L}$CDax)er9K zdBv2z(H`|`o7oVwc$)0a5M{`3_>({MUBbbsyFnqTAs@CKyg@x|CcNHG4HvG@g!r5mOLsJvR_QRes~)`l z><+?+&<8=pcG2mz2hGHA%UlJ4k{L@SI4;e2vbe z5qU4TPq`bZfU?!oWZ;DDx=Db_>XXqV$#sFf4v6e~sui+|EC@>tb}lsr zci^sTk*3VhP~bof(d)DvsB!my+VklJw%i!Fr6Tv*$0umgjPgp;XI3a4KE$5loYv(+ z&#FxA6mrtKD7NQQ-!{FPmcmHdcY)s*E^|88weUvIX1Pt?V#F;byI-59eTOq|U?7QbtQLcCKvUiTRI6M;RxrobPH-PPsg1Gmx?oO2qy_}Z;M<*EbcMm_fy4K^jw$)b?u=b(W+6(S$2@Fq&dws)R=WBa2 zU^%Nn z*)k6h=`=;;g6AOL(0Zcwp@$^yuFiJOD-GKrTv}zb?jt4@u}4b7)gsJNuh??rY@6SnO~X)e7M+-R^Gm z(mpAsb|bdiq*_I$sFT4=8136&#($^SGgMx#XqFNFP2sa=&+LI{Gf~CHhdT#8b*q@l z70w>e-7)D9`g%`oc3Wq9a0i&!6m*e`_`?pY=c zw;r!uVw7%$GuKS#m<3{Q~IjLY96tozjMQ-iTDlXe64>=fYkKgJQjrrRi9RQk*k zLZMyY*Lm9H^eD|{(IC3qqg*hByVKH~WSzO4<`xEi9&L*EA4MEerYX$$+NZ%CdG}Pk zzBeq<7C&-Y*T;UG3?mBJbKkd3$Mb$V$4QAiADf&g82`Izmy|2|rp{eF+s*%|Zpyta z?kgiJ%N3`qzS!;g{`<2Nhrz8ECeX%z1MpwsY|#6GaLf6>>-cUPx%YC0%WqO{H6T1)Xn{q~XK?ISu&>yt~Y!L0a5 z!1c|1DO`U>%2as99#lYGF(>GOw#AhCpMXvMG*FHV#+ZvheVaT_vM-1NVuEVu zV)_@Hdrs;<4pA*IxS8sU8&^%WExhJ#OzYaJYt_J5Aj^~bBg#*DlX4H;xQI3G Pa{qFmF2A&NL-c - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png deleted file mode 100644 index a875fac86f2ed3ccc07fccb8b09ba0e105ec45ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 831 zcmV-F1Hk-=P)|kf5zgAQmDhCPBp)Vu+6-oydX~3QCL?8x&>D%4v05X$DTqz!l(S3|#~8 zO45Ew6VphXmVq9i7T5-SjL=R7jsSgWbo11vknKU+oxl>{Jn$o8$O7`+{I;Z;V)LfH z25jeS?*LW;4`K|d2VM?!^Jz)*1zW_npV?jqYy$ct47(?oSheO?b&Tzs^4_>z4*lr*{t=>n-EBVvS zntfM#GXFd@c}C%{hIUBihoF(7Fpa28k?L!JYU0`0a3qr^cCNU8!hTH>o*^YCf{fkg<;5x8AhOQsjWBXdMi9&6o z)e+CX180C`r4$ck*hYbyK$GpLP8^FY4ZH<*+m3S8@Q7~oT4bjKn~aqtg+pjQay<& zW(^Dg=YRvYKc`$zVv-to1k~Do6{478s2KaJL75pdP;N%OfnNn{#dNWLO0)m~002ov JPDHLkV1iH9T@3&L diff --git a/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png deleted file mode 100755 index 31eba305c99b20837e484c251d21e1660db67eda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 554 zcmV+_0@eMAP)Sleb$n_+F+M%19TZHBeAL2cu(Up-I%~PP{?t3(HBb4_H{FXC9xCdP-kX=mWoZ z`PqwyggIQJ8ZIqB?OJjfxdr6c1k`RCZ;|rTmDE~*+NEO$KBVqbO%qVND%dzk?I4F1 zpmr{X@Es{1!$__Ls9kEdBKMy1`hc6Yc#!hY3b!uMlK03hB%SVeP>Fju$UULB-j|$} zO?)RI;wt~U7%AY#rVth{K{Yzwn&Kv?Hbe$|q}9LkXNlcR z^~jXmC3f8+`yW_pH<`H2{&&4i?7rcOc43zdA0&7eHgP+nvoxJAEE?kGp>Sxx!xYks zKsv4yyosR~c6%s=6>ygio4_pywYauv6mk%olRZd%qd!(KpfZEP9P7Jj!+B!&iDeW{ s?74~pbYcYK8AAQ=w(NdxXpXi307p>@btl0gA^-pY07*qoM6N<$g0tE9a{vGU diff --git a/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png deleted file mode 100644 index e6e661b415d28fc6f15b82a8c64c882cd044cda0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1195 zcmV;c1XTNpP)S^xFtJA1G7 zTYDd-Z)9YIhN+nqyMfec1ZB_&%AgUHK_e)GMo9tG^MQ@P zN0P3sc&znNdwo4k7bbd{9mO`DE{w!b>@VTTRNwaI5J2x@bhO>d^fpJL#5$5lT zn2YBUV5_9JC0!b^k7-F^cNqjelk`N2X*>l%0H*#o_6$JlG@+IrywbYL zpiV)DY!3lf1Mlao@$WQ#Qfb{yGhwaR-UVC_+y#7-v&=b!G`>^Pin4n9FQP2cwp+H} z0>|z1Xe-9wr3Eyo>|uY#*BH13f25Qa`Yru;^Zpv&@f# zthL-`05w`UrKc_hWJOYf_?s`E_QjnxefTsxyabAuBF9VO-{v~gbqy$Mi71+@JEAkR? z6EI}^%c4CcBS_M1gxhIBfin9E+jib(OD7OrkfepcFmOwOGQR@P0nga}qm;(lL=_}y z0WeXZj~4I-@R05A%Ia$}v9F-H1$x*GJYf6168kxUo=eieVL}RCR<#_6e+6~JU%>0Y zdfNxbKbUgeQsD#E=8&UO~rzm+0b9SrsV?+Cn%~PQ-2m(h~G7 z;oLMDyOGJHAm}&35#y;MhsvsmE$ASy$oAKf`)ccYW!++v-&Fo@ z(CxN+a+k`YulWZ{)6)dS3>rZhG=ef{1ZB_&%AgUH!QY)>I<~~63+ey>002ov JPDHLkV1jgJB^Uqz diff --git a/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png deleted file mode 100755 index eda411234db6cdb5b1fec92362fbf15500f67d65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 974 zcmV;<12O!GP)B(X3OyVAJtDCWu$EW1H}i` zt4kMX0lS(^Ha(z~b$Z6;n$iQxgU8I4E)btP(B!h|0g>~9?TnKi5P>#Nm^mGwaOavv zO`1TwZby@*2}HZctfetMAn0+MA!!1^7j`s>s`P-!dCVq;rUyiz!}I1=l@<^LzI2$; zRHp?5JY#cXr3pm4JFTQ9Js`-r!PtKu=xP`GLZDQ6uh`v~vXTcm)DRoEPgFTKTisyE zgJe`2W?P>Nlq%5ba+6CQ^z%&ZN?*w--Iw+gssF1=5EPo>)^wYwy4_&)BthY+ z@;`g{TKYRTg=Rdvcw1Co zyU^4LgF-W=tF#G3wT)r234%g1!hxbed1o6Td63Z^=($KxkCV);K50;+)jg>zg45?Q zOKX;uNfOjx5*LUn?;|G~n)J7<+T@P&w!El%-C!b_WI@d~_qd$$>UE!alLOV8+8yHd zj=7Qm)tJJOz7&Yx>!z(T$-H(w<{ z<9bp~IbCdTKaZOg7C25*|3Q=cLU}D6y4BW-^|sN|1!B3hflf^u=&`hczNj=P@VUHj zJzn$lk26o`R;&Gd-WzhlbypG;XmztCbPD&4-L?GgTHL|9glnrL=p$?SpN#i}qt!me zL5|ig9Isaf6!^-?#{Bu5yA`%q!8XTQ2P^ElN`T&Ry_G^+e-2SthusVcFZdVbuwDN_ zBg}7P_!bt?sjx5YA6kSvTC2iZO_9VB`7Xv}_DeXd~|oWo-;UUbph=vS0jV z>Q49CwCs(RgPg8Yd9^xPRb0?(wl!|79R>e$jB@(c-iFA;0R^rxM*RId>a6PI{x5B!-eQ#hd+u|J4NR`~ wuR6jiYmhNbY)Vs^!bCSFr<}8H0DoZnNhs*GWpI68%MR3exCcf&ULPHpU?aIdwx5g`|x0@|HgKw@eEiKzu7rWTNxT0mlI0g0&vBqo{wNkb$Z6)V3g#JmEI1-_It zHR3U{C-yDqd|;`h6_Q3rK30|#^$eH{Y?Ra`>G*<RH>wxu=dL*5iu#Tjp?gNGZcL3i=nkDIo#Pt+TY5|^;ftP@F zlCDWzOp%mDfag+RwWOtz&Q4uS5tL1UXDaZOr1_GLPF+k)$|?Y081NXdNz%=!i-|>< z1$Zt5mN&ZTi&7U8nj8dpCgZM#N_EpY34jWA)42)o_-=Zoq;aW>X@y(`cqRcG8r}3M zsf!s5xeI{IyXh(j@C?Uw(`zMNow}H2sgeNCWw>s-N7Cu3i|LOl3IOQ9-IlhMy_+tR zfL*}az?r_A-XQ75z}Z14lYrg8mB5|A&cNBDfp;52zHA1kzgQ8RdJZ9EwX7hoIf#reYhXV6}uO&?noIPMlDZpc9Tg_|=a2v2a zaQvCTLP_sS8XGvfX-X}?V`fW%vA`ohZ{Yaraqp&IkaS|;?0(5Y0DzhOZDvp5#?y;{ zzJT$a!2Q7XMmJs9dC5kA$IN~*v+1~h?xw);Bly3YzAA9`A<0gF$ILze#^aW)y8_2g z0M<0R>C*ydA4IkS0L*NEW7#?icp3ONV7%|9H%poqYo~YC0z78+o0-i5CIV{$$Daf& zz`c#TDR6c*1bEDBJ#Z~?y6I8CJB@DooKT^a5&(sD(|*g$F|CGGPJritZh9Q>cHsD- zxFO$`R=erS3hcm1{s!F0yGzmu z1NmXw2mmm%J!bX{ZalpxaQuN=X6|mbn{F!s9y8m~=%(+A5Pt^n2C$@=4FX3-D4_H- znyPz%CnCh}16~6jHM4#j!?qJ3=~mpl=e!8>%hZiHmH?VWd$@g?%obOk6W*YW*IQc%)V_Uq>=(8 zjR#%;E6CDU+kuR`)WFdul;%=QJ&K8WlD zNa_Uc#dWcvzZKgLECwDhvtI&dACjyDNScgW-Ng7>avkn=^HGHC!;;M~PSRXp1>sNr zPC&rKqE9~Lbp?zFoOj^R?2o|NO_P&=kgUWF&Av!oNV^Hxft%a(q%OqME&}%8dW9zk zJ2Z<$I|v9eG>b)*1#HC)%|d_R5tJ$l=mnl5@X#z4RT9t#ECJ@2*$=6UX_l%8_zc&@ zewezDfs#!pd>D2Evw_QVJo%9C72t@PVrDV+>X)9BtDvlgX0b>m zAlgo|+$dT=^qpq8QPeZQ56vdjC;wn#Ujco5)tT zQ0-Yq8!AXDfV?l=ZX=^qBohDy@3_H|x+fJtsNUoDH;*341OQ)o#7-uYNh$!|cCOV7 zOD=#~FFMhpdL0|U_s%nebUXkl6-L;` zOX@_epwa90Fjhqz04afPrf{GiDFRXBaqH_52f#0r(bu9b@U^Igntb76OX?d3z%SFq zAS-xCzNiO&pE}(rK^y?TOb;{I)#IX9@S_LpY;-&TDQSJp=wRO{h+31c-EK9#;sE$% zGDcX%IieS;^{Ue>5)Z&HQ)#SqJW#~lT;5Nfv%e{1;sE$%dYa4bzKu*E=M%TsE-rwS zK!3}*z_$vb6>9W>GpwXr900#e+O$^pr1~Nr_EmY-nZ`<&8xVb#b-Wifh+dsbZDNFU zJOF`_mT`fPWQ%%AdYL8l7nBzeeP*Kw?r-=Sve_;a9(DXryNU&xDEy_cP5A$>dkI?%;(PV<%~(fip|HZZ*7Z-si8 z+a_MpSk%MpryOmZprh{xDvYy%J5>oquiyu#87q+LNI+nSrJYga;(<_=qX2>4 zrnkGd<%K4-JI$lABLH2Evz9xxy(cZGYx@E!46}qQRSA`%7tN__djSHy%=@ZXxO&>nzLwWsZEaaI1?XAU=dqOWOh ztmPN&`4=1Lp`kdK6I$D zEiQkn7M9Pm<}Du}tKJiKG?Six?!q~X776<``^aX1jl80zt~GtR07rY?nO4^SAD86K zwA?%PG3sCcv!9JTr{xYdqC9{>mb8k||9TZCV_H>O?mhdO@Q=T;k(abwjg?iF2T-AF zvoAsQ@}Smw$G(4_3;k{68LjoKncDk>tu8i|r`2;|ALFGAwviXKR^Gt|w>Ka#+Urd8 zT$tHLp4NKrT0}*A15&!$LW59iz3(P3XuT!}8CI%2G)+!6QYzx~@_?+i_KX>2N&(1v z*DaRQQ!4Vz?{#@?=@aYdQHp(JKX}M?ivOI|c0OwZ;YSA - - - diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml deleted file mode 100644 index 137184b86..000000000 --- a/app/src/main/res/drawable/dashed_border_black.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml deleted file mode 100644 index ff714a448..000000000 --- a/app/src/main/res/drawable/dashed_border_dark.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml deleted file mode 100644 index cc71acb72..000000000 --- a/app/src/main/res/drawable/dashed_border_light.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/drawer_header_bottom_background.xml b/app/src/main/res/drawable/drawer_header_bottom_background.xml deleted file mode 100644 index 9f9792340..000000000 --- a/app/src/main/res/drawable/drawer_header_bottom_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index fc2163f43..000000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add_circle_outline.xml b/app/src/main/res/drawable/ic_add_circle_outline.xml deleted file mode 100644 index 0d79d6918..000000000 --- a/app/src/main/res/drawable/ic_add_circle_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml deleted file mode 100644 index b800b1743..000000000 --- a/app/src/main/res/drawable/ic_apps.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml deleted file mode 100644 index 5ed19d5fd..000000000 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml deleted file mode 100644 index da5d30807..000000000 --- a/app/src/main/res/drawable/ic_arrow_drop_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_drop_up.xml b/app/src/main/res/drawable/ic_arrow_drop_up.xml deleted file mode 100644 index df4199d18..000000000 --- a/app/src/main/res/drawable/ic_arrow_drop_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_art_track.xml b/app/src/main/res/drawable/ic_art_track.xml deleted file mode 100644 index 7e61e1044..000000000 --- a/app/src/main/res/drawable/ic_art_track.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_asterisk.xml b/app/src/main/res/drawable/ic_asterisk.xml deleted file mode 100644 index df7c4b32c..000000000 --- a/app/src/main/res/drawable/ic_asterisk.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_attach_money.xml b/app/src/main/res/drawable/ic_attach_money.xml deleted file mode 100644 index b2c0f5c36..000000000 --- a/app/src/main/res/drawable/ic_attach_money.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml deleted file mode 100644 index cf996d197..000000000 --- a/app/src/main/res/drawable/ic_backup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml deleted file mode 100644 index 32cd107f7..000000000 --- a/app/src/main/res/drawable/ic_bookmark.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_bookmark_white.xml b/app/src/main/res/drawable/ic_bookmark_white.xml deleted file mode 100644 index a04ed256e..000000000 --- a/app/src/main/res/drawable/ic_bookmark_white.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_brightness_high.xml b/app/src/main/res/drawable/ic_brightness_high.xml deleted file mode 100644 index d613ed523..000000000 --- a/app/src/main/res/drawable/ic_brightness_high.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_brightness_low.xml b/app/src/main/res/drawable/ic_brightness_low.xml deleted file mode 100644 index 498a67ec0..000000000 --- a/app/src/main/res/drawable/ic_brightness_low.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_brightness_medium.xml b/app/src/main/res/drawable/ic_brightness_medium.xml deleted file mode 100644 index 1f3952586..000000000 --- a/app/src/main/res/drawable/ic_brightness_medium.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml deleted file mode 100644 index c7c44ccb2..000000000 --- a/app/src/main/res/drawable/ic_bug_report.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_campaign.xml b/app/src/main/res/drawable/ic_campaign.xml deleted file mode 100644 index a368f50f6..000000000 --- a/app/src/main/res/drawable/ic_campaign.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_cast.xml b/app/src/main/res/drawable/ic_cast.xml deleted file mode 100644 index 321dfcfc2..000000000 --- a/app/src/main/res/drawable/ic_cast.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_checklist.xml b/app/src/main/res/drawable/ic_checklist.xml deleted file mode 100644 index 27bed183f..000000000 --- a/app/src/main/res/drawable/ic_checklist.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_child_care.xml b/app/src/main/res/drawable/ic_child_care.xml deleted file mode 100644 index 5d2ac1665..000000000 --- a/app/src/main/res/drawable/ic_child_care.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml deleted file mode 100644 index dc0a218b8..000000000 --- a/app/src/main/res/drawable/ic_circle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml deleted file mode 100644 index 1d5133364..000000000 --- a/app/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml deleted file mode 100644 index 15a682b76..000000000 --- a/app/src/main/res/drawable/ic_cloud.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_cloud_download.xml b/app/src/main/res/drawable/ic_cloud_download.xml deleted file mode 100644 index 79c7db8e3..000000000 --- a/app/src/main/res/drawable/ic_cloud_download.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml deleted file mode 100644 index 4bc124a81..000000000 --- a/app/src/main/res/drawable/ic_comment.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_computer.xml b/app/src/main/res/drawable/ic_computer.xml deleted file mode 100644 index 6b0e79313..000000000 --- a/app/src/main/res/drawable/ic_computer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_crop_portrait.xml b/app/src/main/res/drawable/ic_crop_portrait.xml deleted file mode 100644 index 50ce52f91..000000000 --- a/app/src/main/res/drawable/ic_crop_portrait.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index f38c5f130..000000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_description.xml b/app/src/main/res/drawable/ic_description.xml deleted file mode 100644 index 5b80cbefd..000000000 --- a/app/src/main/res/drawable/ic_description.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_directions_bike.xml b/app/src/main/res/drawable/ic_directions_bike.xml deleted file mode 100644 index b5580ee8d..000000000 --- a/app/src/main/res/drawable/ic_directions_bike.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_directions_car.xml b/app/src/main/res/drawable/ic_directions_car.xml deleted file mode 100644 index 3bfd9b4c3..000000000 --- a/app/src/main/res/drawable/ic_directions_car.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml deleted file mode 100644 index 43b77a9cd..000000000 --- a/app/src/main/res/drawable/ic_done.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml deleted file mode 100644 index c08695e98..000000000 --- a/app/src/main/res/drawable/ic_drag_handle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml deleted file mode 100644 index 2e6d23792..000000000 --- a/app/src/main/res/drawable/ic_expand_more.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore.xml b/app/src/main/res/drawable/ic_explore.xml deleted file mode 100644 index 2b974c69f..000000000 --- a/app/src/main/res/drawable/ic_explore.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_fastfood.xml b/app/src/main/res/drawable/ic_fastfood.xml deleted file mode 100644 index b2a1abdf3..000000000 --- a/app/src/main/res/drawable/ic_fastfood.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml deleted file mode 100644 index 87d14880f..000000000 --- a/app/src/main/res/drawable/ic_favorite.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_file_download.xml b/app/src/main/res/drawable/ic_file_download.xml deleted file mode 100644 index b4d9e15e9..000000000 --- a/app/src/main/res/drawable/ic_file_download.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_filter_list.xml b/app/src/main/res/drawable/ic_filter_list.xml deleted file mode 100644 index e1a2b236b..000000000 --- a/app/src/main/res/drawable/ic_filter_list.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_fitness_center.xml b/app/src/main/res/drawable/ic_fitness_center.xml deleted file mode 100644 index 56670cba6..000000000 --- a/app/src/main/res/drawable/ic_fitness_center.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_fullscreen.xml b/app/src/main/res/drawable/ic_fullscreen.xml deleted file mode 100644 index 35e1bbbac..000000000 --- a/app/src/main/res/drawable/ic_fullscreen.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_fullscreen_exit.xml b/app/src/main/res/drawable/ic_fullscreen_exit.xml deleted file mode 100644 index a497da742..000000000 --- a/app/src/main/res/drawable/ic_fullscreen_exit.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_headset.xml b/app/src/main/res/drawable/ic_headset.xml deleted file mode 100644 index 3eff4b7dd..000000000 --- a/app/src/main/res/drawable/ic_headset.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_headset_shadow.xml b/app/src/main/res/drawable/ic_headset_shadow.xml deleted file mode 100644 index 2d6f61eee..000000000 --- a/app/src/main/res/drawable/ic_headset_shadow.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml deleted file mode 100644 index 248f9788b..000000000 --- a/app/src/main/res/drawable/ic_heart.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml deleted file mode 100644 index 45955eae7..000000000 --- a/app/src/main/res/drawable/ic_help.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml deleted file mode 100644 index 4e21de19d..000000000 --- a/app/src/main/res/drawable/ic_history.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_history_white.xml b/app/src/main/res/drawable/ic_history_white.xml deleted file mode 100644 index 585285b89..000000000 --- a/app/src/main/res/drawable/ic_history_white.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml deleted file mode 100644 index 48f968b4c..000000000 --- a/app/src/main/res/drawable/ic_home.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_hourglass_top.xml b/app/src/main/res/drawable/ic_hourglass_top.xml deleted file mode 100644 index f92496779..000000000 --- a/app/src/main/res/drawable/ic_hourglass_top.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_info_outline.xml b/app/src/main/res/drawable/ic_info_outline.xml deleted file mode 100644 index 3bbe51917..000000000 --- a/app/src/main/res/drawable/ic_info_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_insert_emoticon.xml b/app/src/main/res/drawable/ic_insert_emoticon.xml deleted file mode 100644 index 2ddb7120c..000000000 --- a/app/src/main/res/drawable/ic_insert_emoticon.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml deleted file mode 100644 index 8bc821acc..000000000 --- a/app/src/main/res/drawable/ic_language.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml deleted file mode 100644 index f6538e875..000000000 --- a/app/src/main/res/drawable/ic_list.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_live_tv.xml b/app/src/main/res/drawable/ic_live_tv.xml deleted file mode 100644 index 80fb172aa..000000000 --- a/app/src/main/res/drawable/ic_live_tv.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_menu_book.xml b/app/src/main/res/drawable/ic_menu_book.xml deleted file mode 100644 index 4cd4fb3a4..000000000 --- a/app/src/main/res/drawable/ic_menu_book.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml deleted file mode 100644 index 9da90f5a9..000000000 --- a/app/src/main/res/drawable/ic_mic.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml deleted file mode 100644 index 1a873cf8b..000000000 --- a/app/src/main/res/drawable/ic_more_vert.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_motorcycle.xml b/app/src/main/res/drawable/ic_motorcycle.xml deleted file mode 100644 index 7684b0673..000000000 --- a/app/src/main/res/drawable/ic_motorcycle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_movie.xml b/app/src/main/res/drawable/ic_movie.xml deleted file mode 100644 index 49eaf7174..000000000 --- a/app/src/main/res/drawable/ic_movie.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml deleted file mode 100644 index cc4e5bd10..000000000 --- a/app/src/main/res/drawable/ic_music_note.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml deleted file mode 100644 index 2805ebb26..000000000 --- a/app/src/main/res/drawable/ic_next.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml deleted file mode 100644 index f87cac524..000000000 --- a/app/src/main/res/drawable/ic_notifications.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml deleted file mode 100644 index 0356bfe8f..000000000 --- a/app/src/main/res/drawable/ic_palette.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml deleted file mode 100644 index d8f1e440e..000000000 --- a/app/src/main/res/drawable/ic_pause.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_people.xml b/app/src/main/res/drawable/ic_people.xml deleted file mode 100644 index 9cd3ad3fb..000000000 --- a/app/src/main/res/drawable/ic_people.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index db64734ae..000000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pets.xml b/app/src/main/res/drawable/ic_pets.xml deleted file mode 100644 index 0aadab03d..000000000 --- a/app/src/main/res/drawable/ic_pets.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_picture_in_picture.xml b/app/src/main/res/drawable/ic_picture_in_picture.xml deleted file mode 100644 index 91fd52413..000000000 --- a/app/src/main/res/drawable/ic_picture_in_picture.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml deleted file mode 100644 index e41fd7f12..000000000 --- a/app/src/main/res/drawable/ic_pin.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_placeholder_bandcamp.xml b/app/src/main/res/drawable/ic_placeholder_bandcamp.xml deleted file mode 100644 index 411e69854..000000000 --- a/app/src/main/res/drawable/ic_placeholder_bandcamp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_placeholder_media_ccc.xml b/app/src/main/res/drawable/ic_placeholder_media_ccc.xml deleted file mode 100644 index cdc743cb2..000000000 --- a/app/src/main/res/drawable/ic_placeholder_media_ccc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_placeholder_peertube.xml b/app/src/main/res/drawable/ic_placeholder_peertube.xml deleted file mode 100644 index 263d92d70..000000000 --- a/app/src/main/res/drawable/ic_placeholder_peertube.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml deleted file mode 100644 index a70a4ddbb..000000000 --- a/app/src/main/res/drawable/ic_play_arrow.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_play_arrow_shadow.xml b/app/src/main/res/drawable/ic_play_arrow_shadow.xml deleted file mode 100644 index bf4b895b0..000000000 --- a/app/src/main/res/drawable/ic_play_arrow_shadow.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_play_seek_triangle.xml b/app/src/main/res/drawable/ic_play_seek_triangle.xml deleted file mode 100644 index 9c257c423..000000000 --- a/app/src/main/res/drawable/ic_play_seek_triangle.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_playlist_add.xml b/app/src/main/res/drawable/ic_playlist_add.xml deleted file mode 100644 index 144f123b1..000000000 --- a/app/src/main/res/drawable/ic_playlist_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_playlist_add_check.xml b/app/src/main/res/drawable/ic_playlist_add_check.xml deleted file mode 100644 index 54bf2fb56..000000000 --- a/app/src/main/res/drawable/ic_playlist_add_check.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_playlist_play.xml b/app/src/main/res/drawable/ic_playlist_play.xml deleted file mode 100644 index b9c64946a..000000000 --- a/app/src/main/res/drawable/ic_playlist_play.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_podcasts.xml b/app/src/main/res/drawable/ic_podcasts.xml deleted file mode 100644 index c297e8fd3..000000000 --- a/app/src/main/res/drawable/ic_podcasts.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_previous.xml b/app/src/main/res/drawable/ic_previous.xml deleted file mode 100644 index 4af0d2178..000000000 --- a/app/src/main/res/drawable/ic_previous.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_public.xml b/app/src/main/res/drawable/ic_public.xml deleted file mode 100644 index 796f37812..000000000 --- a/app/src/main/res/drawable/ic_public.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_radio.xml b/app/src/main/res/drawable/ic_radio.xml deleted file mode 100644 index f009ff54e..000000000 --- a/app/src/main/res/drawable/ic_radio.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index 20af23dde..000000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml deleted file mode 100644 index fb9ef820b..000000000 --- a/app/src/main/res/drawable/ic_repeat.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_replay.xml b/app/src/main/res/drawable/ic_replay.xml deleted file mode 100644 index 987710fc7..000000000 --- a/app/src/main/res/drawable/ic_replay.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_restaurant.xml b/app/src/main/res/drawable/ic_restaurant.xml deleted file mode 100644 index 9dccc8ee7..000000000 --- a/app/src/main/res/drawable/ic_restaurant.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_rss_feed.xml b/app/src/main/res/drawable/ic_rss_feed.xml deleted file mode 100644 index a73eff527..000000000 --- a/app/src/main/res/drawable/ic_rss_feed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml deleted file mode 100644 index 26e664589..000000000 --- a/app/src/main/res/drawable/ic_save.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_school.xml b/app/src/main/res/drawable/ic_school.xml deleted file mode 100644 index 6d7e2f0e9..000000000 --- a/app/src/main/res/drawable/ic_school.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml deleted file mode 100644 index a889b09e5..000000000 --- a/app/src/main/res/drawable/ic_search.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search_add.xml b/app/src/main/res/drawable/ic_search_add.xml deleted file mode 100644 index 449115e3a..000000000 --- a/app/src/main/res/drawable/ic_search_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_select_all.xml b/app/src/main/res/drawable/ic_select_all.xml deleted file mode 100644 index 19c050773..000000000 --- a/app/src/main/res/drawable/ic_select_all.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index 1e259c6ad..000000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings_backup_restore.xml b/app/src/main/res/drawable/ic_settings_backup_restore.xml deleted file mode 100644 index 580025971..000000000 --- a/app/src/main/res/drawable/ic_settings_backup_restore.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml deleted file mode 100644 index 40971e408..000000000 --- a/app/src/main/res/drawable/ic_share.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shopping_cart.xml b/app/src/main/res/drawable/ic_shopping_cart.xml deleted file mode 100644 index 9e361b60d..000000000 --- a/app/src/main/res/drawable/ic_shopping_cart.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml deleted file mode 100644 index 86717de36..000000000 --- a/app/src/main/res/drawable/ic_shuffle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml deleted file mode 100644 index 93d73aee5..000000000 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml deleted file mode 100644 index a97bebd87..000000000 --- a/app/src/main/res/drawable/ic_sort.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_stars.xml b/app/src/main/res/drawable/ic_stars.xml deleted file mode 100644 index ac5b9dd19..000000000 --- a/app/src/main/res/drawable/ic_stars.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_subscriptions.xml b/app/src/main/res/drawable/ic_subscriptions.xml deleted file mode 100644 index f2ac7bec2..000000000 --- a/app/src/main/res/drawable/ic_subscriptions.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_subtitles.xml b/app/src/main/res/drawable/ic_subtitles.xml deleted file mode 100644 index 43bf3e16b..000000000 --- a/app/src/main/res/drawable/ic_subtitles.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_telescope.xml b/app/src/main/res/drawable/ic_telescope.xml deleted file mode 100644 index e3d5ea33b..000000000 --- a/app/src/main/res/drawable/ic_telescope.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml deleted file mode 100644 index aa828aa50..000000000 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml deleted file mode 100644 index 65d7f78ce..000000000 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_trending_up.xml b/app/src/main/res/drawable/ic_trending_up.xml deleted file mode 100644 index e7fd8e4ae..000000000 --- a/app/src/main/res/drawable/ic_trending_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_tv.xml b/app/src/main/res/drawable/ic_tv.xml deleted file mode 100644 index 91d860eaf..000000000 --- a/app/src/main/res/drawable/ic_tv.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_videogame_asset.xml b/app/src/main/res/drawable/ic_videogame_asset.xml deleted file mode 100644 index 01a91b053..000000000 --- a/app/src/main/res/drawable/ic_videogame_asset.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml deleted file mode 100644 index 06e530961..000000000 --- a/app/src/main/res/drawable/ic_visibility_on.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_down.xml b/app/src/main/res/drawable/ic_volume_down.xml deleted file mode 100644 index 0fe36fad3..000000000 --- a/app/src/main/res/drawable/ic_volume_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_mute.xml b/app/src/main/res/drawable/ic_volume_mute.xml deleted file mode 100644 index b18f6337c..000000000 --- a/app/src/main/res/drawable/ic_volume_mute.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_off.xml b/app/src/main/res/drawable/ic_volume_off.xml deleted file mode 100644 index 420593e04..000000000 --- a/app/src/main/res/drawable/ic_volume_off.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml deleted file mode 100644 index b5a47789b..000000000 --- a/app/src/main/res/drawable/ic_volume_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_watch_later.xml b/app/src/main/res/drawable/ic_watch_later.xml deleted file mode 100644 index 34ecad214..000000000 --- a/app/src/main/res/drawable/ic_watch_later.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_wb_sunny.xml b/app/src/main/res/drawable/ic_wb_sunny.xml deleted file mode 100644 index 922cf72e0..000000000 --- a/app/src/main/res/drawable/ic_wb_sunny.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_whatshot.xml b/app/src/main/res/drawable/ic_whatshot.xml deleted file mode 100644 index 84260ffe4..000000000 --- a/app/src/main/res/drawable/ic_whatshot.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_work.xml b/app/src/main/res/drawable/ic_work.xml deleted file mode 100644 index 014718e60..000000000 --- a/app/src/main/res/drawable/ic_work.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/not_available_monkey.xml b/app/src/main/res/drawable/not_available_monkey.xml deleted file mode 100644 index b15a381c5..000000000 --- a/app/src/main/res/drawable/not_available_monkey.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/placeholder_person.xml b/app/src/main/res/drawable/placeholder_person.xml deleted file mode 100644 index 2b3229e8f..000000000 --- a/app/src/main/res/drawable/placeholder_person.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml b/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml deleted file mode 100644 index de286d860..000000000 --- a/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/placeholder_thumbnail_video.xml b/app/src/main/res/drawable/placeholder_thumbnail_video.xml deleted file mode 100644 index 0b262f923..000000000 --- a/app/src/main/res/drawable/placeholder_thumbnail_video.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/player_controls_background.xml b/app/src/main/res/drawable/player_controls_background.xml deleted file mode 100644 index cd25f2b04..000000000 --- a/app/src/main/res/drawable/player_controls_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/player_controls_top_background.xml b/app/src/main/res/drawable/player_controls_top_background.xml deleted file mode 100644 index 92f9fddca..000000000 --- a/app/src/main/res/drawable/player_controls_top_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/progress_circular_white.xml b/app/src/main/res/drawable/progress_circular_white.xml deleted file mode 100644 index 79e6f54a6..000000000 --- a/app/src/main/res/drawable/progress_circular_white.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml deleted file mode 100644 index 54a850125..000000000 --- a/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml deleted file mode 100644 index 3fb6651fa..000000000 --- a/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml b/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml deleted file mode 100644 index 4815aec7c..000000000 --- a/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_light.xml b/app/src/main/res/drawable/progress_youtube_horizontal_light.xml deleted file mode 100644 index 4c85370d5..000000000 --- a/app/src/main/res/drawable/progress_youtube_horizontal_light.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/selector_checked_dark.xml b/app/src/main/res/drawable/selector_checked_dark.xml deleted file mode 100644 index da05e96c6..000000000 --- a/app/src/main/res/drawable/selector_checked_dark.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/selector_checked_light.xml b/app/src/main/res/drawable/selector_checked_light.xml deleted file mode 100644 index e64b8d083..000000000 --- a/app/src/main/res/drawable/selector_checked_light.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/selector_dark.xml b/app/src/main/res/drawable/selector_dark.xml deleted file mode 100644 index 0c79be292..000000000 --- a/app/src/main/res/drawable/selector_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/selector_focused_dark.xml b/app/src/main/res/drawable/selector_focused_dark.xml deleted file mode 100644 index 508083fcd..000000000 --- a/app/src/main/res/drawable/selector_focused_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/selector_focused_light.xml b/app/src/main/res/drawable/selector_focused_light.xml deleted file mode 100644 index 508083fcd..000000000 --- a/app/src/main/res/drawable/selector_focused_light.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/selector_light.xml b/app/src/main/res/drawable/selector_light.xml deleted file mode 100644 index 0c79be292..000000000 --- a/app/src/main/res/drawable/selector_light.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml deleted file mode 100644 index c9b018add..000000000 --- a/app/src/main/res/drawable/splash_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_foreground.xml b/app/src/main/res/drawable/splash_foreground.xml deleted file mode 100644 index 63fd0351f..000000000 --- a/app/src/main/res/drawable/splash_foreground.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/toolbar_shadow_dark.xml b/app/src/main/res/drawable/toolbar_shadow_dark.xml deleted file mode 100644 index b9419029f..000000000 --- a/app/src/main/res/drawable/toolbar_shadow_dark.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/toolbar_shadow_light.xml b/app/src/main/res/drawable/toolbar_shadow_light.xml deleted file mode 100644 index 6546bdd20..000000000 --- a/app/src/main/res/drawable/toolbar_shadow_light.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml deleted file mode 100644 index ad2655380..000000000 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ /dev/null @@ -1,317 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml deleted file mode 100644 index 793942568..000000000 --- a/app/src/main/res/layout-land/list_stream_card_item.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-large-land/fragment_video_detail.xml b/app/src/main/res/layout-large-land/fragment_video_detail.xml deleted file mode 100644 index d18681056..000000000 --- a/app/src/main/res/layout-large-land/fragment_video_detail.xml +++ /dev/null @@ -1,734 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml deleted file mode 100644 index 661c4affc..000000000 --- a/app/src/main/res/layout/activity_about.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_downloader.xml b/app/src/main/res/layout/activity_downloader.xml deleted file mode 100644 index e3b56e282..000000000 --- a/app/src/main/res/layout/activity_downloader.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/activity_error.xml b/app/src/main/res/layout/activity_error.xml deleted file mode 100644 index 45101c1a1..000000000 --- a/app/src/main/res/layout/activity_error.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -