├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── shetj │ │ └── clinglib │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── shetj │ │ │ └── clinglib │ │ │ ├── DeviceAdapter.kt │ │ │ ├── FileQUtils.kt │ │ │ ├── MainActivity.kt │ │ │ ├── PlayActivity.kt │ │ │ └── Utils.kt │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground2.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_play.xml │ │ └── item_recycle_string.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── network_security_config.xml │ └── test │ └── java │ └── com │ └── shetj │ └── clinglib │ └── ExampleUnitTest.kt ├── build.gradle ├── clinglib ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── android │ │ │ │ └── cling │ │ │ │ ├── ClingDLNAManager.kt │ │ │ │ ├── ClingExt.kt │ │ │ │ ├── control │ │ │ │ ├── CastControlImpl.kt │ │ │ │ ├── CastInterface.kt │ │ │ │ ├── CastSubscriptionCallback.kt │ │ │ │ ├── ServiceAction.kt │ │ │ │ ├── ServiceExecutor.kt │ │ │ │ ├── Utils.kt │ │ │ │ └── action │ │ │ │ │ └── SetNextAVTransportURI.kt │ │ │ │ ├── entity │ │ │ │ ├── ClingDevice.kt │ │ │ │ ├── ClingDeviceList.kt │ │ │ │ └── ClingPlayType.kt │ │ │ │ ├── listener │ │ │ │ └── BrowseRegistryListener.kt │ │ │ │ ├── manager │ │ │ │ ├── IClingManager.kt │ │ │ │ └── IDLNAManager.kt │ │ │ │ ├── service │ │ │ │ ├── ClingUpnpService.kt │ │ │ │ └── LocalFileService.kt │ │ │ │ └── util │ │ │ │ ├── ClingUtils.kt │ │ │ │ └── Utils.kt │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ └── test │ │ └── java │ │ └── me │ │ └── shetj │ │ └── cling │ │ └── ExampleUnitTest.kt └── uploadLocal.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | /.idea 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ClingLib 2 | 3 | 功能:Android DLNA投屏 4 | 5 | **建议使用这位`devin1014`的[DLNA-Cast](https://github.com/devin1014/DLNA-Cast)** 6 | 7 | ## 已实现 8 | 1. 基础投屏功能 9 | 2. 本地资源投屏 10 | 11 | 本项目主要是为自己的业务实现的,如果有需要请自行修改,不提供解决方案 12 | 13 | ### 注意事项 14 | 15 | ```groovy 16 | 17 | allprojects { 18 | dependencyResolutionManagement { 19 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 20 | repositories { 21 | /.../ 22 | maven { url "https://jitpack.io" } 23 | maven { 24 | url 'http://4thline.org/m2' 25 | allowInsecureProtocol = true //当前maven支持http 26 | } 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | 主module需要加入 33 | 34 | ```groovy 35 | packagingOptions { 36 | exclude 'META-INF/beans.xml' 37 | } 38 | ``` 39 | 40 | ### 遇到的问题: 41 | - 设备搜索问题:`cling` 2.1.2 搜索设备有问题,暂时不要用,用2.1.1 42 | - 集成过程中:可能存在`slf4j-simple`重复: `exclude group: 'org.slf4j', module: 'slf4j-simple'` 43 | - 投屏成功没有播放:【有些电视不会自动播放】:调用`setAVTransportURI`投屏后还要调用掉一次play才能播放 44 | - 构建本地服务器只能是http: 需要修改`network_security_config.xml`中的`` 45 | - 暂时不支持`Referer`,项目有代码尝试,但是没有测试,暂时没有条件测试,如果有需要,可以自行测试,然后告诉我结论 46 | - 创建本地服务端口被占用,已经`try`住,会自动加一重新创建,最多重复10次,如果还是不行,就会创建本地服务失败 47 | - 有使用者,视频太大导致播放失败,可以删除LocalFileService中设置setContentLength的对应行,因为cling自带的jetty版本太低,不支持setContentLengthLong 48 | 49 | ### 使用方法 50 | 51 | #### 1. 启动服务 52 | 53 | ```kotlin 54 | mUpnpServiceConnection = startBindUpnpService() 55 | ``` 56 | 57 | #### 2. 停止服务 58 | 59 | ```kotlin 60 | stopUpnpService(mUpnpServiceConnection) 61 | ``` 62 | 63 | #### 3. 搜索设备 64 | 65 | ```kotlin 66 | ClingDLNAManager.getInstant().searchDevices() //默认启动服务后会自动搜索一次 67 | ClingDLNAManager.getInstant().getCurSearchDevices().observe(this) { 68 | mAdapter.setList(it) //展示搜索结果 69 | } 70 | ``` 71 | #### 4. 投屏相关 72 | ```kotlin 73 | control = ClingDLNAManager.getInstant().connectDevice(device) 74 | ``` 75 | ```kotlin 76 | control.setAVTransportURI(uri , title,type, callback) 77 | control.setNextAVTransportURI(uri, title, callback) 78 | control.play(speed = "1", callback) 79 | control.pause(callback) 80 | control.stop(callback) 81 | control.seek(millSeconds, callback) 82 | control.next(callback) 83 | control.canNext(callback) 84 | control.previous(callback) 85 | control.canPrevious(callback) 86 | control.getPositionInfo(callback) 87 | control.getMediaInfo(callback) 88 | control.getTransportInfo(callback) 89 | ``` 90 | 91 | #### 5. 本地资源投屏,投屏期间不可关闭服务【请自行选择是否开启多进程服务】 92 | 93 | -------------------------------- 94 | 95 | **原理**: 96 | 1. 利用`jetty`和`servlet-api`构建**手机本地的服务器**,又因为DLNA投屏需要**手机和电视在同一个局域网中**,所以电视是可以访问到的手机的资源【App要具有对应的权限】。**因此投屏期间不可关闭服务,否则会导致播放失败.** 97 | 2. 构建本地服务器只能是http: 需要修改`network_security_config.xml`中的`` 98 | 3. ~~本地服务的`contentType = "application/octet-stream"`,是一个**通用的 MIME 类型,表示二进制数据流**。所以有些辣鸡的播放器**可能无法识别**,不过你可以增加更多的判断进行设置**contentType**。【**重写LocalFileService**】~~ 99 | - 通过传入路径的文件结尾来判断contentType,如果没有找到,就使用默认的`application/octet-stream`,如果你有更好的方法,欢迎提出来 100 | ```kotlin 101 | put("png", "image/png") 102 | put("jpg", "image/jpeg") 103 | put("mp4", "video/mp4") 104 | put("mov", "video/quicktime") 105 | put("wmv", "video/x-ms-wmv") 106 | put("m3u8", "application/x-mpegURL") 107 | put("mp3","audio/mpeg") 108 | put("m4a","audio/m4a") 109 | put("wav","audio/wav") 110 | put("aac","audio/aac") 111 | ``` 112 | -------------------------------- 113 | ###### 5.1 启动本地服务器 114 | ```Kotlin 115 | ClingDLNAManager.startLocalFileService(this) 116 | ``` 117 | ###### 5.2 选择文件构建本地url【注意权限的获取】 118 | ``` 119 | val url = ClingDLNAManager.getBaseUrl(this) + 本地路径 120 | ``` 121 | 122 | ###### 5.3 关闭服务 123 | ```kotlin 124 | ClingDLNAManager.stopLocalFileService(this) 125 | ``` 126 | - 案例如下:[MainActivity.kt](https://github.com/SheTieJun/clingLib/blob/f83527d57268ffc366fe6a9571af0b2f5a89b1b5/app/src/main/java/com/shetj/clinglib/MainActivity.kt) 127 | - 如果没有wifi: 会是 127.0.0.1,所以请在连接好wifi和设备后,在构建本地的url,防止构建出错误的url,导致无法播放,同时播放中请不要关闭本地的服务器的Service 128 | - 链接组成 : http://xxx.xx.xx.xx:5050/clingLocaleFile//data/user/0/com.shetj.clinglib/cache/1000022406.mp4 129 | - 其中`xxx.xx.xx.xx`是手机链接wifi后的ip地址 130 | - `5050`是端口号 131 | - `clingLocaleFile`是固定的,重写`LocalFileService`可以修改 132 | - `/data/user/0/com.xxx.xxx/xxx/1000022406.mp4`是本地的路径,也可以自己结合`LocalFileService`重新定义,这样就可以不用担心暴露本地路径了 133 | 134 | ## 友情提示 135 | - 当前项目本人已应用到项目,虽然有些地方有修改,但是基础功能都是一样的,并且可以正常使用。所以有问题,请优先自己先调试一下,找找问题,解决不了,在提提问会比较好 136 | - 不建议直接使用,可以先去学习理解其中的原理:**DLNA、UPnP、本地服务器**等等,然后下载下来在按自己的业务进行调整修改 137 | - 这个Lib是因为**乐播收费**才做的需求,目前已经满足自己的业务需求,后续不会再更新,直到有新的需求~ 有些地方可能不是很完善,**如果有需要,可以自行修改,如果有问题,请优先自己先调试一下,找找问题,解决不了,在提提问会比较好,我会尽量解决** 138 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | 6 | android { 7 | compileSdk rootProject.ext.compileSdkVersion 8 | namespace "com.shetj.clinglib" 9 | defaultConfig { 10 | applicationId "com.shetj.clinglib" 11 | minSdk 24 //demo Base 24 12 | targetSdk rootProject.ext.compileSdkVersion 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | packagingOptions { 25 | exclude 'META-INF/beans.xml' 26 | } 27 | 28 | compileOptions { 29 | sourceCompatibility = JavaVersion.VERSION_1_8 30 | targetCompatibility = JavaVersion.VERSION_1_8 31 | } 32 | 33 | kotlinOptions { 34 | jvmTarget = JavaVersion.VERSION_1_8.toString() 35 | } 36 | 37 | buildFeatures { 38 | viewBinding = true 39 | dataBinding = true 40 | } 41 | } 42 | 43 | dependencies { 44 | implementation fileTree(dir: 'libs', include: ['*.jar']) 45 | testImplementation 'junit:junit:4.12' 46 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 47 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 48 | implementation "com.github.SheTieJun:Base:4ad830925f" 49 | implementation project(path: ':clinglib') 50 | def media3_version = "1.1.1" 51 | // For media playback using ExoPlayer 52 | implementation ("androidx.media3:media3-exoplayer:$media3_version") 53 | // For DASH playback support with ExoPlayer 54 | implementation ("androidx.media3:media3-exoplayer-dash:$media3_version") 55 | // For HLS playback support with ExoPlayer 56 | implementation ("androidx.media3:media3-exoplayer-hls:$media3_version") 57 | // For RTSP playback support with ExoPlayer 58 | implementation ("androidx.media3:media3-exoplayer-rtsp:$media3_version") 59 | 60 | // For loading data using the Cronet network stack 61 | implementation("androidx.media3:media3-datasource-cronet:$media3_version") 62 | // For loading data using the OkHttp network stack 63 | implementation("androidx.media3:media3-datasource-okhttp:$media3_version") 64 | // For loading data using librtmp 65 | implementation("androidx.media3:media3-datasource-rtmp:$media3_version") 66 | 67 | // For building media playback UIs 68 | implementation ("androidx.media3:media3-ui:$media3_version") 69 | // For exposing and controlling media sessions 70 | implementation ("androidx.media3:media3-session:$media3_version") 71 | } 72 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/shetj/clinglib/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.shetj.clinglib 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.lizhiweik.clinglib", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SheTieJun/clingLib/a3624d064853c9605ee6f105dbb954c7b46d880a/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/shetj/clinglib/DeviceAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.shetj.clinglib 2 | 3 | import android.graphics.Color 4 | import com.android.cling.entity.ClingDevice 5 | import com.chad.library.adapter.base.viewholder.BaseViewHolder 6 | import me.shetj.base.base.BaseSAdapter 7 | 8 | class DeviceAdapter(data:MutableList?=null) : BaseSAdapter(R.layout.item_recycle_string,data) { 9 | 10 | private var playPosition = -1 11 | 12 | private var oldPosition = -1 13 | 14 | override fun convert(holder: BaseViewHolder, item: ClingDevice) { 15 | item.let { 16 | if (holder.layoutPosition == playPosition) { 17 | holder.setText(R.id.tv_string, "选中:【${holder.bindingAdapterPosition}】" +item.name) 18 | } else { 19 | holder.setText(R.id.tv_string,"【${holder.bindingAdapterPosition}】" +item.name) 20 | } 21 | holder.setTextColor(R.id.tv_string,when(holder.layoutPosition == playPosition){ 22 | true -> Color.RED 23 | false -> Color.BLACK 24 | }) 25 | } 26 | } 27 | 28 | 29 | 30 | fun setPlay(i: Int) { 31 | if (playPosition != i) { 32 | playPosition = i 33 | if (oldPosition != -1) { 34 | notifyItemChanged(oldPosition) 35 | } 36 | oldPosition = playPosition 37 | notifyItemChanged(playPosition) 38 | } 39 | } 40 | 41 | fun removeDevice(device: ClingDevice) { 42 | data.find { 43 | it.device.equals(device) 44 | }?.apply { 45 | remove(this) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/shetj/clinglib/FileQUtils.kt: -------------------------------------------------------------------------------- 1 | package com.shetj.clinglib 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.ContentResolver 5 | import android.content.ContentUris 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.database.Cursor 9 | import android.net.Uri 10 | import android.os.Build.VERSION 11 | import android.os.Build.VERSION_CODES 12 | import android.os.Environment 13 | import android.os.FileUtils 14 | import android.provider.DocumentsContract 15 | import android.provider.MediaStore.Audio 16 | import android.provider.MediaStore.Images 17 | import android.provider.MediaStore.Images.ImageColumns 18 | import android.provider.MediaStore.MediaColumns 19 | import android.provider.MediaStore.Video 20 | import android.provider.OpenableColumns 21 | import android.webkit.MimeTypeMap 22 | import androidx.annotation.RequiresApi 23 | import java.io.File 24 | import java.io.FileOutputStream 25 | import me.shetj.base.ktx.md5 26 | 27 | /** 28 | * 安卓Q 文件基础操作 29 | */ 30 | object FileQUtils { 31 | 32 | /** 33 | * 根据Uri获取文件绝对路径,解决Android4.4以上版本Uri转换 兼容Android 10 34 | * 35 | * @param context 36 | * @param uri 37 | * @param filename10IsTemp 是否是临时文件 38 | */ 39 | fun getFileAbsolutePath(context: Context?, uri: Uri?,filename10IsTemp:Boolean = true): String? { 40 | if (context == null || uri == null) { 41 | return null 42 | } 43 | if (VERSION.SDK_INT < VERSION_CODES.KITKAT) { 44 | return getRealFilePath(context, uri) 45 | } 46 | if (VERSION.SDK_INT >= VERSION_CODES.KITKAT && VERSION.SDK_INT < VERSION_CODES.Q && DocumentsContract.isDocumentUri( 47 | context, 48 | uri 49 | ) 50 | ) { 51 | if (isExternalStorageDocument(uri)) { 52 | val docId = DocumentsContract.getDocumentId(uri) 53 | val split = docId.split(":").toTypedArray() 54 | val type = split[0] 55 | if ("primary".equals(type, ignoreCase = true)) { 56 | return Environment.getExternalStorageDirectory().toString() + "/" + split[1] 57 | } 58 | } else if (isDownloadsDocument(uri)) { 59 | val id = DocumentsContract.getDocumentId(uri) 60 | if (id.startsWith("raw:")) { 61 | return id.replaceFirst("raw:", "") 62 | } 63 | val contentUri = ContentUris.withAppendedId( 64 | Uri.parse("content://downloads/public_downloads"), 65 | java.lang.Long.valueOf(id) 66 | ) 67 | return getDataColumn(context, contentUri, null, null) 68 | } else if (isMediaDocument(uri)) { 69 | val docId = DocumentsContract.getDocumentId(uri) 70 | val split = docId.split(":").toTypedArray() 71 | val type = split[0] 72 | var contentUri: Uri? = null 73 | when (type) { 74 | "image" -> { 75 | contentUri = Images.Media.EXTERNAL_CONTENT_URI 76 | } 77 | 78 | "video" -> { 79 | contentUri = Video.Media.EXTERNAL_CONTENT_URI 80 | } 81 | 82 | "audio" -> { 83 | contentUri = Audio.Media.EXTERNAL_CONTENT_URI 84 | } 85 | } 86 | val selection = MediaColumns._ID + "=?" 87 | val selectionArgs = arrayOf(split[1]) 88 | return getDataColumn(context, contentUri, selection, selectionArgs) 89 | } 90 | } // MediaStore (and general) 91 | if (VERSION.SDK_INT >= VERSION_CODES.Q) { 92 | return uriToFileApiQ(context, uri,filename10IsTemp) 93 | } else if ("content".equals(uri.scheme, ignoreCase = true)) { 94 | // Return the remote address 95 | return if (isGooglePhotosUri(uri)) { 96 | uri.lastPathSegment 97 | } else getDataColumn( 98 | context, 99 | uri, 100 | null, 101 | null 102 | ) 103 | } else if ("file".equals(uri.scheme, ignoreCase = true)) { 104 | return uri.path 105 | } 106 | return null 107 | } 108 | 109 | //此方法 只能用于4.4以下的版本 110 | private fun getRealFilePath(context: Context, uri: Uri?): String? { 111 | if (null == uri) { 112 | return null 113 | } 114 | val scheme = uri.scheme 115 | var data: String? = null 116 | if (scheme == null) { 117 | data = uri.path 118 | } else if (ContentResolver.SCHEME_FILE == scheme) { 119 | data = uri.path 120 | } else if (ContentResolver.SCHEME_CONTENT == scheme) { 121 | val projection = arrayOf(ImageColumns.DATA) 122 | val cursor = context.contentResolver.query(uri, projection, null, null, null) 123 | if (null != cursor) { 124 | if (cursor.moveToFirst()) { 125 | val index = cursor.getColumnIndex(ImageColumns.DATA) 126 | if (index > -1) { 127 | data = cursor.getString(index) 128 | } 129 | } 130 | cursor.close() 131 | } 132 | } 133 | return data 134 | } 135 | 136 | /** 137 | * @param uri The Uri to check. 138 | * @return Whether the Uri authority is ExternalStorageProvider. 139 | */ 140 | private fun isExternalStorageDocument(uri: Uri): Boolean { 141 | return "com.android.externalstorage.documents" == uri.authority 142 | } 143 | 144 | /** 145 | * @param uri The Uri to check. 146 | * @return Whether the Uri authority is DownloadsProvider. 147 | */ 148 | private fun isDownloadsDocument(uri: Uri): Boolean { 149 | return "com.android.providers.downloads.documents" == uri.authority 150 | } 151 | 152 | private fun getDataColumn( 153 | context: Context, 154 | uri: Uri?, 155 | selection: String?, 156 | selectionArgs: Array? 157 | ): String? { 158 | var cursor: Cursor? = null 159 | val column = MediaColumns.DATA 160 | val projection = arrayOf(column) 161 | try { 162 | cursor = context.contentResolver.query(uri!!, projection, selection, selectionArgs, null) 163 | if (cursor != null && cursor.moveToFirst()) { 164 | val index = cursor.getColumnIndexOrThrow(column) 165 | return cursor.getString(index) 166 | } 167 | } finally { 168 | cursor?.close() 169 | } 170 | return null 171 | } 172 | 173 | /** 174 | * @param uri The Uri to check. 175 | * @return Whether the Uri authority is MediaProvider. 176 | */ 177 | private fun isMediaDocument(uri: Uri): Boolean { 178 | return "com.android.providers.media.documents" == uri.authority 179 | } 180 | 181 | /** 182 | * @param uri The Uri to check. 183 | * @return Whether the Uri authority is Google Photos. 184 | */ 185 | private fun isGooglePhotosUri(uri: Uri): Boolean { 186 | return "com.google.android.apps.photos.content" == uri.authority 187 | } 188 | 189 | /** 190 | * Android 10 以上适配 另一种写法 191 | * @param context 192 | * @param uri 193 | * @return 194 | */ 195 | @SuppressLint("Range") 196 | private fun getFileFromContentUri(context: Context, uri: Uri?): String? { 197 | if (uri == null) { 198 | return null 199 | } 200 | val filePath: String 201 | val filePathColumn = arrayOf(MediaColumns.DATA, MediaColumns.DISPLAY_NAME) 202 | val contentResolver = context.contentResolver 203 | val cursor = contentResolver.query( 204 | uri, filePathColumn, null, 205 | null, null 206 | ) 207 | if (cursor != null) { 208 | cursor.moveToFirst() 209 | try { 210 | filePath = cursor.getString(cursor.getColumnIndex(filePathColumn[0])) 211 | return filePath 212 | } catch (e: Exception) { 213 | } finally { 214 | cursor.close() 215 | } 216 | } 217 | return "" 218 | } 219 | 220 | /** 221 | * Android 10 以上适配 222 | * @param context 223 | * @param uri 224 | * @return 225 | */ 226 | @RequiresApi(api = VERSION_CODES.Q) 227 | private fun uriToFileApiQ(context: Context, uri: Uri, filename10IsTemp: Boolean): String? { 228 | return if (uri.scheme == ContentResolver.SCHEME_FILE) 229 | File(requireNotNull(uri.path)).path 230 | else if (uri.scheme == ContentResolver.SCHEME_CONTENT) { 231 | // 把文件保存到沙盒,算是临时文件 232 | val contentResolver = context.contentResolver 233 | val displayName = getFileName(context, uri,filename10IsTemp).replace("/", "_") //修复复制文件时,文件名中包含/导致的文件复制失败 234 | val ios = contentResolver.openInputStream(uri) 235 | if (ios != null) { 236 | File("${context.cacheDir.absolutePath}/$displayName") 237 | .apply { 238 | val fos = FileOutputStream(this) 239 | FileUtils.copy(ios, fos) 240 | fos.close() 241 | ios.close() 242 | }.path 243 | } else null 244 | } else null 245 | } 246 | 247 | private fun getFileName(context: Context, uri: Uri, filename10IsTemp: Boolean = true): String { 248 | var fileName: String? = null 249 | val contentResolver = context.contentResolver 250 | if (uri.scheme == ContentResolver.SCHEME_CONTENT) { 251 | val cursor = contentResolver.query(uri, null, null, null, null) 252 | cursor.use { cursor -> 253 | if (cursor != null && cursor.moveToFirst()) { 254 | fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) 255 | } 256 | } 257 | } 258 | if (fileName == null) { 259 | fileName = uri.lastPathSegment 260 | } 261 | if (!filename10IsTemp && fileName !=null) { 262 | fileName = System.currentTimeMillis().toString() + fileName 263 | } 264 | return fileName ?: "${uri.toString().md5}.${ 265 | MimeTypeMap.getSingleton() 266 | .getExtensionFromMimeType(contentResolver.getType(uri)) 267 | }" 268 | } 269 | 270 | 271 | /** 272 | * Take file permission 273 | * 获取长时间的文件读取权限 274 | * @param uri 275 | */ 276 | fun takeFilePermission(context: Context, uri: Uri) { 277 | val flag = Intent.FLAG_GRANT_READ_URI_PERMISSION 278 | context.contentResolver.takePersistableUriPermission(uri, flag) 279 | } 280 | } 281 | 282 | /** 283 | * 删除文件 284 | */ 285 | fun Context.delFile(uri: Uri) { 286 | DocumentsContract.deleteDocument(contentResolver, uri) 287 | } 288 | 289 | 290 | -------------------------------------------------------------------------------- /app/src/main/java/com/shetj/clinglib/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.shetj.clinglib 2 | 3 | import android.Manifest.permission 4 | import android.content.* 5 | import android.os.Build.VERSION 6 | import android.os.Build.VERSION_CODES 7 | import android.os.Bundle 8 | import android.util.Log 9 | import android.widget.Toast 10 | import androidx.activity.result.PickVisualMediaRequest.Builder 11 | import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly 12 | import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.VideoOnly 13 | import com.android.cling.ClingDLNAManager 14 | import com.android.cling.control.DeviceControl 15 | import com.android.cling.control.OnDeviceControlListener 16 | import com.android.cling.control.ServiceActionCallback 17 | import com.android.cling.entity.ClingPlayType 18 | import com.android.cling.startBindUpnpService 19 | import com.android.cling.stopUpnpService 20 | import com.google.android.material.divider.MaterialDividerItemDecoration 21 | import com.shetj.clinglib.databinding.ActivityMainBinding 22 | import me.shetj.base.ktx.hasPermission 23 | import me.shetj.base.ktx.launch 24 | import me.shetj.base.ktx.loadImage 25 | import me.shetj.base.ktx.pickVisualMedia 26 | import me.shetj.base.ktx.setAppearance 27 | import me.shetj.base.ktx.showToast 28 | import me.shetj.base.ktx.toJson 29 | import me.shetj.base.mvvm.viewbind.BaseBindingActivity 30 | import me.shetj.base.mvvm.viewbind.BaseViewModel 31 | import me.shetj.base.network_coroutine.KCHttpV2 32 | import me.shetj.base.tools.app.NetworkUtils 33 | import me.shetj.base.tools.app.Tim 34 | import org.fourthline.cling.model.meta.Device 35 | 36 | class MainActivity : BaseBindingActivity() { 37 | private lateinit var mAdapter: DeviceAdapter 38 | private var control: DeviceControl ?=null 39 | private var mUpnpServiceConnection: ServiceConnection? = null 40 | 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | Tim.setLogAuto(true) 44 | } 45 | 46 | override fun initBaseView() { 47 | super.initBaseView() 48 | setAppearance(true) 49 | initView() 50 | initData() 51 | if (!NetworkUtils.isAvailable(this) ||! NetworkUtils.isWifiConnected(this)) { 52 | "请先连接wifi".showToast() 53 | } 54 | } 55 | 56 | 57 | fun initView() { 58 | mBinding.startScreen.setOnClickListener { 59 | if (control == null){ 60 | "请先选择设备".showToast() 61 | return@setOnClickListener 62 | } 63 | val url = "https://200024424.vod.myqcloud.com/200024424_709ae516bdf811e6ad39991f76a4df69.f20.mp4" 64 | control?.setAVTransportURI(url,"直播视频介绍", ClingPlayType.TYPE_VIDEO, object : ServiceActionCallback { 65 | override fun onSuccess(result: Unit) { 66 | "投放成功".showToast() 67 | control?.play() //有些还要重新调用一次播放 68 | } 69 | 70 | override fun onFailure(msg: String) { 71 | "投放失败:$msg".showToast() 72 | } 73 | }) 74 | } 75 | 76 | mBinding.stopScreen.setOnClickListener { 77 | if (control == null){ 78 | "请先选择设备".showToast() 79 | return@setOnClickListener 80 | } 81 | control?.stop(object : ServiceActionCallback { 82 | override fun onSuccess(result: Unit) { 83 | "停止成功".showToast() 84 | } 85 | 86 | override fun onFailure(msg: String) { 87 | "停止失败".showToast() 88 | } 89 | }) 90 | } 91 | mBinding.search.setOnClickListener { 92 | if (NetworkUtils.isAvailable(this) && NetworkUtils.isWifiConnected(this)) { 93 | "开始搜索...".showToast() 94 | ClingDLNAManager.getInstant().searchDevices() 95 | }else{ 96 | "请先连接wifi".showToast() 97 | } 98 | } 99 | 100 | mBinding.localService.setOnClickListener { 101 | val hasPermission = if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { 102 | hasPermission(permission.READ_MEDIA_VIDEO, permission.READ_MEDIA_IMAGES, isRequest = true) 103 | } else { 104 | hasPermission(permission.READ_EXTERNAL_STORAGE, isRequest = true) 105 | } 106 | if (hasPermission) { 107 | val builder = Builder().apply { 108 | setMediaType(VideoOnly) 109 | } 110 | pickVisualMedia(inputType = builder.build()) { 111 | if (it == null) return@pickVisualMedia 112 | val url = ClingDLNAManager.getBaseUrl(this) + FileQUtils.getFileAbsolutePath(this, it) 113 | PlayActivity.start(this,url) 114 | } 115 | } 116 | } 117 | showRecycleView() 118 | } 119 | 120 | private fun initData() { 121 | bindServices() 122 | ClingDLNAManager.startLocalFileService(this) 123 | } 124 | 125 | 126 | private fun bindServices() { // Bind UPnP service 127 | mUpnpServiceConnection = startBindUpnpService { 128 | Log.i("Cling", "startBindUpnpService OK") 129 | } 130 | } 131 | 132 | private fun showRecycleView() { 133 | mAdapter = DeviceAdapter().apply { 134 | setOnItemClickListener { _, _, position -> 135 | getItem(position).apply { 136 | control = ClingDLNAManager.getInstant().connectDevice(this, object : OnDeviceControlListener { 137 | override fun onConnected(device: Device<*, *, *>) { 138 | super.onConnected(device) 139 | Toast.makeText(this@MainActivity, "连接成功", Toast.LENGTH_SHORT).show() 140 | } 141 | 142 | override fun onDisconnected(device: Device<*, *, *>) { 143 | super.onDisconnected(device) 144 | Toast.makeText(this@MainActivity, "无法连接: ${device.details.friendlyName}", Toast.LENGTH_SHORT).show() 145 | } 146 | }) 147 | control?.addControlObservers() 148 | setPlay(position) 149 | mBinding.tvMsg.text = "您选择了:${this.name}" 150 | } 151 | } 152 | } 153 | mBinding.iRecyclerView.adapter = mAdapter 154 | mBinding.iRecyclerView.addItemDecoration(MaterialDividerItemDecoration(this, MaterialDividerItemDecoration.VERTICAL)) 155 | ClingDLNAManager.getInstant().getSearchDevices().observe(this) { 156 | mBinding.toolbar.subtitle = Utils.getWiFiIpAddress(this) 157 | mAdapter.setList(it) 158 | } 159 | } 160 | 161 | /** 162 | * Add control observers 163 | * 监听control的状态 164 | */ 165 | private fun DeviceControl.addControlObservers() { 166 | getCurrentState().observe(this@MainActivity) { 167 | mBinding.playState.text = "当前状态:$it" 168 | } 169 | getCurrentPositionInfo().observe(this@MainActivity){ 170 | mBinding.playPosition.text = "当前进度:${it.toJson()}" 171 | } 172 | getCurrentVolume().observe(this@MainActivity){ 173 | mBinding.playVolume.text = "当前音量:$it" 174 | } 175 | getCurrentMute().observe(this@MainActivity) { 176 | mBinding.playVolume.text = "当前静音:$it" 177 | } 178 | } 179 | 180 | override fun setUpClicks() { 181 | super.setUpClicks() 182 | mBinding.localVideo.setOnClickListener { 183 | choiceLocalVideoAndImage("视频",ClingPlayType.TYPE_VIDEO) 184 | } 185 | } 186 | 187 | 188 | private fun choiceLocalVideoAndImage(title: String, type: ClingPlayType) { 189 | if (control == null){ 190 | "请先选择设备".showToast() 191 | return 192 | } 193 | val hasPermission = if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { 194 | hasPermission(permission.READ_MEDIA_VIDEO, permission.READ_MEDIA_IMAGES, isRequest = true) 195 | } else { 196 | hasPermission(permission.READ_EXTERNAL_STORAGE, isRequest = true) 197 | } 198 | if (hasPermission) { 199 | val builder = Builder().apply { 200 | when (type) { 201 | ClingPlayType.TYPE_VIDEO -> { 202 | setMediaType(VideoOnly) 203 | } 204 | ClingPlayType.TYPE_IMAGE -> { 205 | setMediaType(ImageOnly) 206 | } 207 | else -> { 208 | Toast.makeText(this@MainActivity, "暂不支持", Toast.LENGTH_SHORT).show() 209 | return 210 | } 211 | } 212 | } 213 | pickVisualMedia(inputType = builder.build()) { 214 | if (it == null) return@pickVisualMedia 215 | val url = ClingDLNAManager.getBaseUrl(this) + FileQUtils.getFileAbsolutePath(this, it) 216 | control?.setAVTransportURI(url,title, type, object : ServiceActionCallback { 217 | override fun onSuccess(result: Unit) { 218 | "投放成功".showToast() 219 | control?.play() //有些还要重新调用一次播放 220 | } 221 | 222 | override fun onFailure(msg: String) { 223 | "投放失败:$msg".showToast() 224 | } 225 | }) 226 | } 227 | } 228 | } 229 | 230 | 231 | override fun onBackPressed() { 232 | super.onBackPressed() 233 | finish() 234 | } 235 | 236 | 237 | override fun onDestroy() { 238 | ClingDLNAManager.stopLocalFileService(this) 239 | stopUpnpService(mUpnpServiceConnection) 240 | ClingDLNAManager.getInstant().destroy() 241 | super.onDestroy() 242 | } 243 | } -------------------------------------------------------------------------------- /app/src/main/java/com/shetj/clinglib/PlayActivity.kt: -------------------------------------------------------------------------------- 1 | package com.shetj.clinglib 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.media3.common.MediaItem 6 | import androidx.media3.exoplayer.ExoPlayer 7 | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory 8 | import androidx.media3.ui.AspectRatioFrameLayout 9 | import com.shetj.clinglib.databinding.ActivityPlayBinding 10 | import me.shetj.base.base.AbBindingActivity 11 | import me.shetj.base.ktx.logI 12 | import me.shetj.base.ktx.start 13 | 14 | /** 15 | * 16 | * @author: shetj
17 | * @createTime: 2023/9/18
18 | */ 19 | class PlayActivity:AbBindingActivity() { 20 | 21 | companion object{ 22 | fun start(context: Context,url:String){ 23 | context.start(Intent(context,PlayActivity::class.java).apply { 24 | putExtra("url",url) 25 | }) 26 | } 27 | } 28 | 29 | 30 | @androidx.media3.common.util.UnstableApi 31 | override fun onInitialized() { 32 | super.onInitialized() 33 | intent.getStringExtra("url")?.let { 34 | mBinding.tvMsg.text= "当前播放链接:$it" 35 | "当前播放链接:$it".logI("PlayActivity") 36 | val mediaSource = DefaultMediaSourceFactory(this) 37 | .createMediaSource(MediaItem.fromUri(it)) 38 | val player = ExoPlayer.Builder(this).build() 39 | player.setMediaSource(mediaSource) 40 | player.prepare() 41 | mBinding.playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT 42 | mBinding.playerView.player = player 43 | } 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/shetj/clinglib/Utils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Kevin Shen 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.shetj.clinglib 17 | 18 | import android.Manifest.permission 19 | import android.content.Context 20 | import android.content.pm.PackageManager 21 | import android.database.Cursor 22 | import android.net.ConnectivityManager 23 | import android.net.Uri 24 | import android.net.wifi.WifiInfo 25 | import android.net.wifi.WifiManager 26 | import android.os.Build 27 | import android.os.Environment 28 | import android.provider.DocumentsContract 29 | import android.provider.MediaStore.MediaColumns 30 | import android.provider.MediaStore.Video 31 | import java.io.File 32 | 33 | object Utils { 34 | // ------------------------------------------------------------------------------------------------------------------------ 35 | // ---- Device Wifi Information 36 | // ------------------------------------------------------------------------------------------------------------------------ 37 | private const val UNKNOWN = "" 38 | private const val WIFI_DISABLED = "" 39 | private const val WIFI_NO_CONNECT = "" 40 | private const val PERMISSION_DENIED = "" 41 | 42 | fun getWiFiIpAddress(context: Context): String { 43 | var ipAddress = 0 44 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { 45 | val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 46 | ipAddress = wifiManager.connectionInfo.ipAddress 47 | } else { 48 | val connectivityManager = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 49 | connectivityManager.run { 50 | activeNetwork?.let { network -> 51 | (getNetworkCapabilities(network)?.transportInfo as? WifiInfo)?.let { wifiInfo -> 52 | ipAddress = wifiInfo.ipAddress 53 | } 54 | } 55 | } 56 | } 57 | if (ipAddress == 0) return "" 58 | return (ipAddress and 0xFF).toString() + "." + (ipAddress shr 8 and 0xFF) + "." + (ipAddress shr 16 and 0xFF) + "." + (ipAddress shr 24 and 0xFF) 59 | } 60 | 61 | /** 62 | * need permission 'Manifest.permission.ACCESS_FINE_LOCATION' and 'Manifest.permission.ACCESS_WIFI_STATE' if system sdk >= Android O. 63 | */ 64 | fun getWiFiName(context: Context): String { 65 | val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 66 | if (!wifiManager.isWifiEnabled) return WIFI_DISABLED 67 | val wifiInfo = wifiManager.connectionInfo ?: return WIFI_NO_CONNECT 68 | return if (wifiInfo.ssid == WifiManager.UNKNOWN_SSID) { 69 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 70 | if (context.checkSelfPermission(permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { 71 | if (wifiManager.configuredNetworks != null) { 72 | for (config in wifiManager.configuredNetworks) { 73 | if (config.networkId == wifiInfo.networkId) { 74 | return config.SSID.replace("\"".toRegex(), "") 75 | } 76 | } 77 | } 78 | } else { 79 | PERMISSION_DENIED 80 | } 81 | } else { 82 | return WIFI_NO_CONNECT 83 | } 84 | UNKNOWN 85 | } else { 86 | wifiInfo.ssid.replace("\"".toRegex(), "") 87 | } 88 | } 89 | 90 | fun getHttpBaseUrl(context: Context, port: Int = 9091) = "http://${getWiFiIpAddress(context)}:$port/" 91 | 92 | // ------------------------------------------------------------------------------------------------------------------------ 93 | // ---- Others 94 | // ------------------------------------------------------------------------------------------------------------------------ 95 | fun parseUri2File(context: Context, uri: Uri): File? { 96 | return if (DocumentsContract.isDocumentUri(context, uri)) { 97 | val docId = DocumentsContract.getDocumentId(uri) 98 | val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() 99 | val type = split[0] 100 | val path = if ("primary".equals(type, ignoreCase = true)) { 101 | Environment.getExternalStorageDirectory().toString() + "/" + split[1] 102 | } else if ("raw".equals(type, ignoreCase = true)) { 103 | split[1] 104 | } else { 105 | getDataColumn(context, Video.Media.EXTERNAL_CONTENT_URI, "${MediaColumns._ID}=?", arrayOf(split[1])) 106 | } 107 | path?.let { File(it) } 108 | } else if ("content".equals(uri.scheme, ignoreCase = true)) { 109 | getDataColumn(context, uri)?.let { File(it) } 110 | } else if ("file".equals(uri.scheme, ignoreCase = true)) { 111 | uri.path?.let { File(it) } 112 | } else { 113 | null 114 | } 115 | } 116 | 117 | private fun getDataColumn(context: Context, uri: Uri, selection: String? = null, selectionArgs: Array? = null): String? { 118 | val projection = arrayOf(MediaColumns.DATA) 119 | var cursor: Cursor? = null 120 | try { 121 | cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) 122 | if (cursor != null && cursor.moveToFirst()) { 123 | val columnIndex = cursor.getColumnIndexOrThrow(projection[0]) 124 | return cursor.getString(columnIndex) 125 | } 126 | } catch (e: Exception) { 127 | e.printStackTrace() 128 | } finally { 129 | try { 130 | cursor?.close() 131 | } catch (e: Exception) { 132 | e.printStackTrace() 133 | } 134 | } 135 | return null 136 | } 137 | 138 | 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground2.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | 14 | 16 | 18 | 20 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | 19 | 20 | 26 | 27 | 34 | 35 | 36 | 39 |