Skip to content

Commit 2eda88a

Browse files
committed
Fix network timeout issues and improve error handling
1 parent debeaab commit 2eda88a

File tree

10 files changed

+153
-865
lines changed

10 files changed

+153
-865
lines changed

.idea/caches/deviceStreaming.xml

Lines changed: 0 additions & 835 deletions
This file was deleted.

app/build.gradle.kts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ android {
1717
applicationId = "com.samyak.simpletube"
1818
minSdk = 26
1919
targetSdk = 35
20-
versionCode = 2
21-
versionName = "0.2.0"
20+
versionCode = 5
21+
versionName = "0.2.2.1"
2222
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2323
}
2424
buildTypes {
@@ -126,6 +126,12 @@ android {
126126
disable += "ByteOrderMark"
127127
}
128128

129+
sourceSets {
130+
getByName("main") {
131+
java.setSrcDirs(listOf("src/main/java"))
132+
}
133+
}
134+
129135
}
130136

131137
ksp {

app/src/main/java/com/samyak/simpletube/MainActivity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,8 @@ class MainActivity : ComponentActivity() {
576576
if (youtubeNavigator(it.toUri())) {
577577
// don't do anything
578578
} else {
579+
// Use urlEncode() but it will be decoded in ViewModel
580+
// This maintains URL safety while allowing spaces in search
579581
navController.navigate("search/${it.urlEncode()}")
580582
if (dataStore[PauseSearchHistoryKey] != true) {
581583
database.query {

app/src/main/java/com/samyak/simpletube/playback/MusicService.kt

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.net.ConnectivityManager
1212
import android.net.Uri
1313
import android.os.Binder
1414
import android.os.Build
15+
import android.util.Log
1516
import android.widget.Toast
1617
import androidx.core.app.NotificationChannelCompat
1718
import androidx.core.app.NotificationManagerCompat
@@ -270,6 +271,52 @@ class MusicService : MediaLibraryService(),
270271
).show()
271272
return
272273
}
274+
2004 -> {
275+
// Error 2004 - I/O unspecified error, usually expired stream URL
276+
// This is a critical error that needs immediate stream refresh
277+
278+
val currentMediaItem = player.currentMediaItem
279+
if (currentMediaItem != null && consecutivePlaybackErr < 3) {
280+
consecutivePlaybackErr++
281+
282+
Toast.makeText(
283+
this@MusicService,
284+
"Stream expired. Refreshing... (${consecutivePlaybackErr}/3)",
285+
Toast.LENGTH_SHORT
286+
).show()
287+
288+
// Force clear the cache and reload with fresh stream
289+
val currentPosition = player.currentPosition
290+
val wasPlaying = player.isPlaying
291+
292+
// Remove and re-add the media item to force URL refresh
293+
player.removeMediaItem(player.currentMediaItemIndex)
294+
player.addMediaItem(player.currentMediaItemIndex, currentMediaItem)
295+
player.seekTo(player.currentMediaItemIndex, currentPosition)
296+
297+
if (wasPlaying) {
298+
player.prepare()
299+
player.play()
300+
} else {
301+
player.prepare()
302+
}
303+
return
304+
} else {
305+
// After 3 retries, skip to next track
306+
Toast.makeText(
307+
this@MusicService,
308+
"Unable to refresh stream. Skipping...",
309+
Toast.LENGTH_SHORT
310+
).show()
311+
312+
consecutivePlaybackErr = 0
313+
if (dataStore.get(SkipOnErrorKey, true)) {
314+
skipOnError()
315+
} else {
316+
stopOnError()
317+
}
318+
}
319+
}
273320
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> {
274321
waitOnNetworkError()
275322
return
@@ -821,10 +868,16 @@ class MusicService : MediaLibraryService(),
821868
return@Factory dataSpec
822869
}
823870

824-
songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let {
871+
// Check cache with buffer time (refresh 30 seconds before expiry to prevent 2004 errors)
872+
val currentTime = System.currentTimeMillis()
873+
val bufferTime = 30000L // 30 seconds buffer
874+
songUrlCache[mediaId]?.takeIf { it.second > currentTime + bufferTime }?.let {
825875
scope.launch(Dispatchers.IO) { recoverSong(mediaId) }
826876
return@Factory dataSpec.withUri(it.first.toUri())
827877
}
878+
879+
// Clear expired or soon-to-expire cache entry to force refresh
880+
songUrlCache.remove(mediaId)
828881

829882
// Check whether format exists so that users from older version can view format details
830883
// There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently
@@ -883,9 +936,17 @@ class MusicService : MediaLibraryService(),
883936
scope.launch(Dispatchers.IO) { recoverSong(mediaId, playbackData) }
884937

885938
val streamUrl = playbackData.streamUrl
886-
887-
songUrlCache[mediaId] =
888-
streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L)
939+
val expiryTime = System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L)
940+
941+
// Validate stream URL before caching to prevent error 2004
942+
if (playbackData.streamExpiresInSeconds < 60) {
943+
// Stream expires in less than 60 seconds - this is suspicious
944+
Log.w("MusicService", "[$mediaId] Stream expires very soon: ${playbackData.streamExpiresInSeconds}s")
945+
}
946+
947+
songUrlCache[mediaId] = streamUrl to expiryTime
948+
Log.d("MusicService", "[$mediaId] Cached stream URL, expires in ${playbackData.streamExpiresInSeconds}s")
949+
889950
dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH)
890951
}
891952
}

app/src/main/java/com/samyak/simpletube/ui/player/PlaybackError.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ fun PlaybackError(
6767
Text(
6868
text = when (error.errorCode) {
6969
2000 -> error.message ?: "This content requires YouTube sign-in"
70+
2004 -> "Stream unavailable. Retrying with different source..."
7071
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "No internet connection"
7172
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> "Connection timeout"
7273
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND -> "File not found"

app/src/main/java/com/samyak/simpletube/ui/screens/search/OnlineSearchResult.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ fun OnlineSearchResult(
167167
val songSuggestions = collection.filter { it is SongItem }
168168
playerConnection.playQueue(
169169
ListQueue(
170-
title = "${context.getString(R.string.queue_searched_songs_ot)} ${ URLDecoder.decode(viewModel.query, "UTF-8")}",
170+
title = "${context.getString(R.string.queue_searched_songs_ot)} ${viewModel.query}",
171171
items = songSuggestions.map { (it as SongItem).toMediaMetadata() },
172172
startIndex = songSuggestions.indexOf(item)
173173
),
@@ -214,20 +214,25 @@ fun OnlineSearchResult(
214214
.asPaddingValues()
215215
) {
216216
if (searchFilter == null) {
217+
// Show search summary (ALL filter)
217218
searchSummary?.summaries?.forEach { summary ->
218-
item {
219-
NavigationTitle(summary.title)
220-
}
219+
// Only show sections that have items
220+
if (summary.items.isNotEmpty()) {
221+
item {
222+
NavigationTitle(summary.title)
223+
}
221224

222-
items(
223-
items = summary.items,
224-
key = { "${summary.title}/${it.id}" }
225-
) { item ->
226-
ytItemContent(item, summary.items)
225+
items(
226+
items = summary.items,
227+
key = { "${summary.title}/${it.id}" }
228+
) { item ->
229+
ytItemContent(item, summary.items)
230+
}
227231
}
228232
}
229233

230-
if (searchSummary?.summaries?.isEmpty() == true) {
234+
// Only show "no results" if we have a summary but ALL sections are empty
235+
if (searchSummary != null && searchSummary.summaries.all { it.items.isEmpty() }) {
231236
item {
232237
EmptyPlaceholder(
233238
icon = Icons.Rounded.Search,

app/src/main/java/com/samyak/simpletube/utils/YTPlayerUtils.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.samyak.simpletube.utils.potoken.PoTokenResult
1818
import com.zionhuang.innertube.NewPipeUtils
1919
import com.zionhuang.innertube.YouTube
2020
import com.zionhuang.innertube.models.YouTubeClient
21+
import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_VR_NO_AUTH
2122
import com.zionhuang.innertube.models.YouTubeClient.Companion.IOS
2223
import com.zionhuang.innertube.models.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER
2324
import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX
@@ -39,18 +40,23 @@ object YTPlayerUtils {
3940
* Do not use other clients for this because it can result in inconsistent metadata.
4041
* For example other clients can have different normalization targets (loudnessDb).
4142
*
43+
* [com.zionhuang.innertube.models.YouTubeClient.ANDROID_VR_NO_AUTH] Is temporally used as it is out only working client
4244
* [com.zionhuang.innertube.models.YouTubeClient.WEB_REMIX] should be preferred here because currently it is the only client which provides:
4345
* - the correct metadata (like loudnessDb)
4446
* - premium formats
4547
*/
46-
val MAIN_CLIENT: YouTubeClient = WEB_REMIX
48+
val MAIN_CLIENT: YouTubeClient = ANDROID_VR_NO_AUTH
4749

4850
/**
4951
* Clients used for fallback streams in case the streams of the main client do not work.
5052
*/
5153
val STREAM_FALLBACK_CLIENTS: Array<YouTubeClient> = arrayOf(
52-
TVHTML5_SIMPLY_EMBEDDED_PLAYER,
53-
IOS,
54+
// Could not parse deobfuscation function
55+
// WEB_REMIX,
56+
// ANDROID,
57+
// TVHTML5,
58+
// TVHTML5_SIMPLY_EMBEDDED_PLAYER,
59+
IOS, // recent api changes produce error 403 after 30 seconds
5460
)
5561

5662
data class PlaybackData(
@@ -219,7 +225,7 @@ object YTPlayerUtils {
219225
videoId: String,
220226
playlistId: String? = null,
221227
): Result<PlayerResponse> =
222-
YouTube.player(videoId, playlistId, client = MAIN_CLIENT)
228+
YouTube.player(videoId, playlistId, client = WEB_REMIX) // ANDROID_VR does not work with history
223229

224230
private fun findFormat(
225231
playerResponse: PlayerResponse,

app/src/main/java/com/samyak/simpletube/viewmodels/OnlineSearchViewModel.kt

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,39 @@ import com.samyak.simpletube.utils.reportException
1414
import dagger.hilt.android.lifecycle.HiltViewModel
1515
import kotlinx.coroutines.flow.MutableStateFlow
1616
import kotlinx.coroutines.launch
17+
import java.net.URLDecoder
1718
import javax.inject.Inject
1819

1920
@HiltViewModel
2021
class OnlineSearchViewModel @Inject constructor(
2122
savedStateHandle: SavedStateHandle,
2223
) : ViewModel() {
23-
val query = savedStateHandle.get<String>("query")!!
24+
// Decode URL-encoded query (e.g., "mama+chi+porgi" → "mama chi porgi")
25+
val query = URLDecoder.decode(savedStateHandle.get<String>("query")!!, "UTF-8")
2426
val filter = MutableStateFlow<YouTube.SearchFilter?>(null)
2527
var summaryPage by mutableStateOf<SearchSummaryPage?>(null)
2628
val viewStateMap = mutableStateMapOf<String, ItemsPage?>()
2729

2830
init {
31+
// Load search results immediately on initialization
32+
viewModelScope.launch {
33+
// Load summary (ALL filter) immediately
34+
YouTube.searchSummary(query)
35+
.onSuccess {
36+
summaryPage = it
37+
}
38+
.onFailure {
39+
reportException(it)
40+
// Don't set empty summary - keep it null to show error state properly
41+
// The UI will handle null state differently than empty state
42+
}
43+
}
44+
45+
// Listen for filter changes
2946
viewModelScope.launch {
3047
filter.collect { filter ->
3148
if (filter == null) {
32-
if (summaryPage == null) {
33-
YouTube.searchSummary(query)
34-
.onSuccess {
35-
summaryPage = it
36-
}
37-
.onFailure {
38-
reportException(it)
39-
}
40-
}
49+
// Already loaded in init above
4150
} else {
4251
if (viewStateMap[filter.value] == null) {
4352
YouTube.search(query, filter)
@@ -46,6 +55,8 @@ class OnlineSearchViewModel @Inject constructor(
4655
}
4756
.onFailure {
4857
reportException(it)
58+
// Set empty results to stop shimmer
59+
viewStateMap[filter.value] = ItemsPage(emptyList(), null)
4960
}
5061
}
5162
}

innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ class InnerTube {
5353
private fun createClient() = HttpClient(OkHttp) {
5454
expectSuccess = true
5555

56+
// Configure timeouts to prevent ERR_TIMED_OUT errors
57+
install(HttpTimeout) {
58+
requestTimeoutMillis = 30000 // 30 seconds for request
59+
connectTimeoutMillis = 15000 // 15 seconds for connection
60+
socketTimeoutMillis = 30000 // 30 seconds for socket read/write
61+
}
62+
5663
install(ContentNegotiation) {
5764
json(Json {
5865
ignoreUnknownKeys = true
@@ -69,6 +76,22 @@ class InnerTube {
6976
if (proxy != null) {
7077
engine {
7178
proxy = this@InnerTube.proxy
79+
80+
// Configure OkHttp engine timeouts as well
81+
config {
82+
connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
83+
readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
84+
writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
85+
}
86+
}
87+
} else {
88+
engine {
89+
// Configure OkHttp engine timeouts even without proxy
90+
config {
91+
connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
92+
readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
93+
writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
94+
}
7295
}
7396
}
7497

innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,13 @@ data class YouTubeClient(
8686
userAgent = "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
8787
osVersion = "18.3.2.22D82",
8888
)
89+
90+
val ANDROID_VR_NO_AUTH = YouTubeClient(
91+
clientName = "ANDROID_VR",
92+
clientVersion = "1.60.19",
93+
clientId = "28",
94+
userAgent = "com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 14; eureka-user Build/UP1A.231005.007) gzip",
95+
osVersion = "14",
96+
)
8997
}
9098
}

0 commit comments

Comments
 (0)