Skip to content

Commit cb3dd8e

Browse files
yinnhoclaude
andauthored
Refactor to instance-based core with suspend-native internals (#6)
Replace static/companion-object state with a single CoreManager instance created by init() and released by cleanup(): the engine owns its scope, discovery, controllers and caches, so re-initialization cannot leak or resurrect stale state. CacheManager and LocalCastManager become suspend-native (callback adapters removed) and castLocalFile throws typed UPnPException subclasses raised at the failure source instead of parsing message strings. Calling APIs before init() now returns neutral defaults instead of silently auto-initializing. Public API signatures are unchanged. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b9b0df2 commit cb3dd8e

8 files changed

Lines changed: 508 additions & 907 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- **Local file server**: serve correct MIME types by file extension (some TVs reject `application/octet-stream`) and skip reliably to the requested Range offset (`InputStream.skip` may under-skip, corrupting seeked streams); bound the token registry (LRU, 64 entries) so repeated casts no longer leak entries
2323

2424
### 🔧 Changed
25+
- **Internal architecture**: the whole casting stack (coroutine scope, SSDP discovery, media controllers, caches) is now owned by a single `CoreManager` instance created in `init()` and released in `cleanup()` — the static/companion-object state, the global scope registry and the weak-reference controller registry are gone, so repeated `cleanup()``init()` cycles can no longer leak or resurrect stale state
26+
- **Native suspend internals**: internal layers (`CacheManager`, `LocalCastManager`, progress/volume queries) are suspend-native; the `suspendCancellableCoroutine` callback adapters and callback-threading through the internals were removed. Public API signatures are unchanged
27+
- **Typed errors for local casting**: `castLocalFile()` now throws `UPnPException.FileError` / `NetworkError` / `DeviceError` raised at the failure source, instead of mapping message strings back to exception types after the fact
28+
- **Not-initialized behavior**: calling cast/control/query APIs before `init()` returns neutral defaults (`false` / `null` / `IDLE` / empty list) instead of silently auto-initializing; `castLocalFile()` throws `UPnPException.UnknownError` instead of hanging forever
2529
- `DeviceDescriptionParser` is now `internal` (was accidentally public)
2630
- Removed unused `okhttp`/`gson` dependencies
2731
- Removed phantom `VideoSelectorActivity` declaration from the library manifest
Lines changed: 67 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,34 @@
11
package com.yinnho.upnpcast
22

33
import android.content.Context
4-
import kotlinx.coroutines.*
5-
import kotlinx.coroutines.suspendCancellableCoroutine
6-
import kotlin.coroutines.resume
7-
import com.yinnho.upnpcast.internal.core.CoreManager
84
import com.yinnho.upnpcast.internal.UPnPException
5+
import com.yinnho.upnpcast.internal.core.CoreManager
6+
import com.yinnho.upnpcast.internal.localcast.LocalCastManager
97

108
/**
119
* Modern UPnP/DLNA casting interface (pure coroutine version)
1210
* Architecture: DLNACast -> CoreManager -> DlnaMediaController
11+
*
12+
* The engine ([CoreManager]) is created by [init] and replaced atomically;
13+
* before [init] (or after [cleanup]) queries return neutral defaults and
14+
* [castLocalFile] throws.
1315
*/
1416
object DLNACast {
15-
16-
/**
17-
* Generic coroutine converter for single return value
18-
*/
19-
private suspend inline fun <T> suspendOnce(
20-
crossinline block: (callback: (T) -> Unit) -> Unit
21-
): T = suspendCancellableCoroutine { cont ->
22-
var resumed = false
23-
block { result ->
24-
if (!resumed) {
25-
resumed = true
26-
cont.resume(result)
27-
}
28-
}
29-
}
30-
31-
/**
32-
* Generic coroutine converter for success/failure pattern
33-
*/
34-
private suspend inline fun <T> suspendWithSuccess(
35-
crossinline block: (callback: (T, success: Boolean) -> Unit) -> Unit
36-
): T? = suspendCancellableCoroutine { cont ->
37-
var resumed = false
38-
block { result, success ->
39-
if (!resumed) {
40-
resumed = true
41-
if (success) cont.resume(result) else cont.resume(null)
42-
}
43-
}
44-
}
45-
46-
/**
47-
* Generic coroutine converter for triple parameter pattern
48-
*/
49-
private suspend inline fun <T1, T2> suspendWithTriple(
50-
crossinline block: (callback: (T1, T2, success: Boolean) -> Unit) -> Unit
51-
): Pair<T1, T2>? = suspendCancellableCoroutine { cont ->
52-
var resumed = false
53-
block { param1, param2, success ->
54-
if (!resumed) {
55-
resumed = true
56-
if (success) cont.resume(Pair(param1, param2)) else cont.resume(null)
57-
}
58-
}
59-
}
60-
17+
18+
@Volatile
19+
private var engine: CoreManager? = null
20+
6121
data class Device(
6222
val id: String,
6323
val name: String,
6424
val address: String,
6525
val isTV: Boolean
6626
)
67-
27+
6828
enum class PlaybackState {
6929
IDLE, PLAYING, PAUSED, STOPPED, BUFFERING, ERROR
7030
}
71-
31+
7232
enum class MediaAction(val value: String) {
7333
PLAY("play"),
7434
PAUSE("pause"),
@@ -77,7 +37,7 @@ object DLNACast {
7737
MUTE("mute"),
7838
SEEK("seek")
7939
}
80-
40+
8141
data class State(
8242
val isConnected: Boolean,
8343
val currentDevice: Device?,
@@ -89,7 +49,7 @@ object DLNACast {
8949
val isPaused: Boolean get() = playbackState == PlaybackState.PAUSED
9050
val isIdle: Boolean get() = playbackState == PlaybackState.IDLE
9151
}
92-
52+
9353
data class LocalVideo(
9454
val id: String,
9555
val title: String,
@@ -98,14 +58,13 @@ object DLNACast {
9858
val size: String,
9959
val durationMs: Long
10060
)
101-
61+
10262
/**
10363
* Generic media control method
10464
*/
105-
suspend fun control(action: MediaAction, value: Any? = null): Boolean {
106-
return CoreManager.controlMediaSuspend(action.value, value)
107-
}
108-
65+
suspend fun control(action: MediaAction, value: Any? = null): Boolean =
66+
engine?.controlMedia(action.value, value) ?: false
67+
10968
/**
11069
* Convenient control methods
11170
*/
@@ -115,29 +74,21 @@ object DLNACast {
11574
suspend fun setVolume(volume: Int): Boolean = control(MediaAction.VOLUME, volume)
11675
suspend fun setMute(mute: Boolean): Boolean = control(MediaAction.MUTE, mute)
11776
suspend fun seek(positionMs: Long): Boolean = control(MediaAction.SEEK, positionMs)
118-
77+
11978
/**
12079
* Search for DLNA devices
12180
*
12281
* Returns the complete list of devices found within [timeout]; does not
12382
* resolve early when the first device appears.
12483
*/
12584
suspend fun search(timeout: Long = 5000): List<Device> =
126-
suspendCancellableCoroutine { cont ->
127-
var resumed = false
128-
CoreManager.search(timeout) { devices, complete ->
129-
if (complete && !resumed) {
130-
resumed = true
131-
cont.resume(devices)
132-
}
133-
}
134-
}
135-
85+
engine?.search(timeout) ?: emptyList()
86+
13687
/**
13788
* Cast media to best available device
13889
*/
13990
suspend fun cast(url: String, title: String? = null, options: CastOptions = CastOptions()): Boolean =
140-
suspendOnce { callback -> CoreManager.cast(url, title, options, callback) }
91+
engine?.cast(url, title, options) ?: false
14192

14293
/**
14394
* Cast media to specific device
@@ -148,100 +99,92 @@ object DLNACast {
14899
title: String? = null,
149100
options: CastOptions = CastOptions()
150101
): Boolean =
151-
suspendOnce { callback -> CoreManager.castToDevice(device, url, title, options, callback) }
152-
102+
engine?.castToDevice(device, url, title, options) ?: false
103+
153104
/**
154105
* Get current playback progress
155106
*/
156-
suspend fun getProgress(): Pair<Long, Long>? =
157-
suspendWithTriple { callback -> CoreManager.getProgress(callback) }
107+
suspend fun getProgress(): Pair<Long, Long>? = engine?.getProgress()
158108

159109
/**
160110
* Query the live playback state from the connected device
161111
* (GetTransportInfo; reflects pause/stop on the device side)
162112
*/
163-
suspend fun getPlaybackState(): PlaybackState = CoreManager.getPlaybackState()
164-
113+
suspend fun getPlaybackState(): PlaybackState = engine?.getPlaybackState() ?: PlaybackState.IDLE
114+
165115
/**
166116
* Get volume information
167117
*/
168-
suspend fun getVolume(): Pair<Int?, Boolean?>? =
169-
suspendWithTriple { callback -> CoreManager.getVolume(callback) }
170-
118+
suspend fun getVolume(): Pair<Int?, Boolean?>? = engine?.getVolume()
119+
171120
/**
172121
* Scan local videos on device
173122
*/
174-
suspend fun scanLocalVideos(context: Context): List<LocalVideo> =
175-
suspendOnce { callback -> CoreManager.scanLocalVideos(context, callback) }
176-
123+
suspend fun scanLocalVideos(context: Context): List<LocalVideo> =
124+
LocalCastManager.scanLocalVideos(context)
125+
177126
/**
178127
* Cast local file to device
128+
*
129+
* @throws UPnPException.FileError the file does not exist or cannot be read
130+
* @throws UPnPException.NetworkError the local file server could not be started
131+
* @throws UPnPException.DeviceError the device was not found or rejected the cast
132+
* @throws UPnPException.UnknownError the library is not initialized
179133
*/
180134
suspend fun castLocalFile(
181135
filePath: String,
182136
device: Device,
183137
title: String? = null,
184138
options: CastOptions = CastOptions()
185139
) {
186-
suspendCancellableCoroutine<Unit> { cont ->
187-
var resumed = false
188-
CoreManager.castLocalFileToDevice(filePath, device, title, options) { success, message ->
189-
if (!resumed) {
190-
resumed = true
191-
if (success) {
192-
cont.resume(Unit)
193-
} else {
194-
val exception = when {
195-
"file" in message.lowercase() || "not found" in message.lowercase() ->
196-
UPnPException.FileError(message)
197-
"network" in message.lowercase() || "connection" in message.lowercase() || "timeout" in message.lowercase() ->
198-
UPnPException.NetworkError(message)
199-
"device" in message.lowercase() ->
200-
UPnPException.DeviceError(message)
201-
else -> UPnPException.UnknownError(message)
202-
}
203-
cont.resumeWith(Result.failure(exception))
204-
}
205-
}
206-
}
207-
}
140+
val core = engine ?: throw UPnPException.UnknownError("DLNACast is not initialized")
141+
core.castLocalFile(filePath, device, title, options)
208142
}
209-
143+
210144
/**
211145
* Get real-time progress (force refresh cache)
212146
*/
213-
suspend fun getProgressRealtime(): Pair<Long, Long>? =
214-
suspendWithTriple { callback -> CoreManager.getProgressRealtime(callback) }
215-
147+
suspend fun getProgressRealtime(): Pair<Long, Long>? = engine?.getProgressRealtime()
148+
216149
/**
217150
* Refresh volume cache
218151
*/
219-
suspend fun refreshVolumeCache(): Boolean =
220-
suspendOnce { callback -> CoreManager.refreshVolumeCache(callback) }
221-
152+
suspend fun refreshVolumeCache(): Boolean = engine?.refreshVolumeCache() ?: false
153+
222154
/**
223155
* Refresh progress cache
224156
*/
225-
suspend fun refreshProgressCache(): Boolean =
226-
suspendOnce { callback -> CoreManager.refreshProgressCache(callback) }
227-
157+
suspend fun refreshProgressCache(): Boolean = engine?.refreshProgressCache() ?: false
158+
228159
/**
229-
* Initialize DLNA service
160+
* Initialize DLNA service; replaces any previous engine
230161
*/
231-
fun init(context: Context) = CoreManager.init(context)
232-
162+
fun init(context: Context) {
163+
cleanup()
164+
engine = CoreManager(context.applicationContext)
165+
}
166+
233167
/**
234168
* Get current casting state
235169
*/
236-
fun getState(): State = CoreManager.getCurrentState()
237-
170+
fun getState(): State = engine?.getCurrentState() ?: State(
171+
isConnected = false,
172+
currentDevice = null,
173+
playbackState = PlaybackState.IDLE
174+
)
175+
238176
/**
239177
* Clear progress cache (call when switching media)
240178
*/
241-
fun clearProgressCache() = CoreManager.clearProgressCache()
242-
179+
fun clearProgressCache() {
180+
engine?.clearProgressCache()
181+
}
182+
243183
/**
244184
* Clean up all resources
245185
*/
246-
fun cleanup() = CoreManager.cleanup()
247-
}
186+
fun cleanup() {
187+
engine?.shutdown()
188+
engine = null
189+
}
190+
}

0 commit comments

Comments
 (0)