diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d5db1ed05..5635d0e3a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -69,7 +69,7 @@ jobs: with: java-version: '17' distribution: 'temurin' - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/wrapper-validation-action@v2 - uses: gradle/actions/setup-gradle@v3 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cb658a5d..bdaa3dca1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: VERSION_NAME: ${{ github.ref_name }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check if pre release tag id: check-tag run: | @@ -27,7 +27,7 @@ jobs: echo "tag = ${GITHUB_REF_NAME}" echo "version_name = ${VERSION_NAME}" - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' diff --git a/.idea/kotlinScripting.xml b/.idea/kotlinScripting.xml deleted file mode 100644 index 1ff683d26..000000000 --- a/.idea/kotlinScripting.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - 2 - - - 0 - - - 1 - - - 3 - - - 4 - - - 5 - - - diff --git a/build.gradle.kts b/build.gradle.kts index a4e1981ed..f7cc0e466 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -101,6 +101,20 @@ dependencyAnalysis { } } + project(":pillarbox-demo") { + onUnusedDependencies { + // This dependency is not used directly, but required to have previews in Android Studio + exclude(libs.androidx.compose.ui.tooling.asProvider()) + } + } + + project(":pillarbox-demo-tv") { + onUnusedDependencies { + // This dependency is not used directly, but required to have previews in Android Studio + exclude(libs.androidx.compose.ui.tooling.asProvider()) + } + } + project(":pillarbox-player") { onUnusedDependencies { // These dependencies are not used directly, but automatically used by libs.androidx.media3.exoplayer @@ -109,5 +123,12 @@ dependencyAnalysis { exclude(libs.mockk.android) } } + + project(":pillarbox-ui") { + onUnusedDependencies { + // This dependency is not used directly, but required to have previews in Android Studio + exclude(libs.androidx.compose.ui.tooling.asProvider()) + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f18e8959..cdcae0757 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] accompanist = "0.34.0" -android-gradle-plugin = "8.2.2" +android-gradle-plugin = "8.3.0" androidx-activity = "1.8.2" androidx-annotation = "1.7.1" -androidx-compose = "2024.02.00" +androidx-compose = "2024.02.01" # https://developer.android.com/jetpack/androidx/releases/compose-kotlin -androidx-compose-compiler = "1.5.9" +androidx-compose-compiler = "1.5.10" androidx-core = "1.12.0" androidx-fragment = "1.6.2" androidx-leanback = "1.0.0" @@ -21,20 +21,20 @@ androidx-test-runner = "1.5.2" androidx-tv = "1.0.0-alpha10" coil = "2.5.0" comscore = "6.10.0" -dependency-analysis-gradle-plugin = "1.29.0" +dependency-analysis-gradle-plugin = "1.30.0" detekt = "1.23.5" guava = "31.1-android" -json = "20231013" +json = "20240205" junit = "4.13.2" kotlin = "1.9.22" kotlinx-coroutines = "1.8.0" kotlinx-kover = "0.7.6" -kotlinx-serialization = "1.6.2" -ktor = "2.3.8" -mockk = "1.13.9" +kotlinx-serialization = "1.6.3" +ktor = "2.3.9" +mockk = "1.13.10" okhttp = "4.12.0" robolectric = "4.11.1" -srg-data-provider = "0.8.0" +srg-data-provider = "0.8.2" tag-commander-core = "5.4.3" tag-commander-server-side = "5.5.2" turbine = "1.0.0" diff --git a/pillarbox-core-business/build.gradle.kts b/pillarbox-core-business/build.gradle.kts index 3e6834564..2f03aad46 100644 --- a/pillarbox-core-business/build.gradle.kts +++ b/pillarbox-core-business/build.gradle.kts @@ -100,15 +100,7 @@ dependencies { testImplementation(libs.mockk.dsl) testRuntimeOnly(libs.robolectric) testImplementation(libs.robolectric.annotations) - testRuntimeOnly(libs.robolectric.shadows.framework) - - androidTestImplementation(project(":pillarbox-player-testutils")) - - androidTestImplementation(libs.androidx.test.monitor) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.junit) - androidTestRuntimeOnly(libs.kotlinx.coroutines.android) - androidTestImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric.shadows.framework) } koverReport { diff --git a/pillarbox-core-business/src/androidTest/assets/media-compositions.json b/pillarbox-core-business/src/androidTest/assets/media-compositions.json deleted file mode 100644 index 31bc396be..000000000 --- a/pillarbox-core-business/src/androidTest/assets/media-compositions.json +++ /dev/null @@ -1,1521 +0,0 @@ -[ - { - "chapterUrn": "urn:rts:audio:3262363", - "episode": { - "id": "3262367", - "title": "Couleur 3 en direct", - "publishedDate": "2011-07-11T14:20:07+02:00", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3" - }, - "show": { - "id": "3262370", - "vendor": "RTS", - "transmission": "RADIO", - "urn": "urn:rts:show:radio:3262370", - "title": "Couleur 3 en direct", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "bannerImageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/3x1", - "posterImageUrl": "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg", - "posterImageIsFallbackUrl": true, - "primaryChannelId": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "primaryChannelUrn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "audioDescriptionAvailable": false, - "subtitlesAvailable": false, - "multiAudioLanguagesAvailable": false, - "allowIndexing": false - }, - "channel": { - "id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "vendor": "RTS", - "urn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "title": "Couleur 3", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "transmission": "RADIO" - }, - "chapterList": [ - { - "id": "3262363", - "mediaType": "AUDIO", - "vendor": "RTS", - "urn": "urn:rts:audio:3262363", - "title": "Couleur 3 en direct", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "type": "LIVESTREAM", - "date": "2011-07-11T14:20:07+02:00", - "duration": 0, - "playableAbroad": true, - "displayable": true, - "position": 0, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Livestream", - "media_type": "Audio", - "media_segment_id": "3262363", - "media_episode_length": "0", - "media_segment_length": "0", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "1", - "media_duration_category": "infinit.livestream", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:audio:3262363" - }, - "fullLengthMarkIn": 0, - "fullLengthMarkOut": 0, - "resourceList": [ - { - "url": "http://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8?", - "quality": "HD", - "protocol": "HLS-DVR", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": true, - "live": true, - "mediaContainer": "MPEG2_TS", - "audioCodec": "AAC", - "videoCodec": "NONE", - "tokenType": "NONE", - "analyticsMetadata": { - "media_streaming_quality": "HD", - "media_special_format": "DEFAULT", - "media_url": "http://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8?" - }, - "streamOffset": 55000 - } - ] - } - ], - "analyticsData": { - "srg_pr_id": "3262367", - "srg_plid": "3262370", - "ns_st_pl": "Livestream", - "ns_st_pr": "Couleur 3 en direct", - "ns_st_dt": "2011-07-11", - "ns_st_ddt": "2011-07-11", - "ns_st_tdt": "2011-07-11", - "ns_st_tm": "14:20:07", - "ns_st_tep": "*null", - "ns_st_li": "1", - "ns_st_stc": "0867", - "ns_st_st": "Couleur 3", - "ns_st_tpr": "11562086", - "ns_st_en": "*null", - "ns_st_ge": "*null", - "ns_st_ia": "*null", - "ns_st_ce": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc", - "srg_unit": "RTS", - "srg_c1": "live", - "srg_c2": "rts.ch_audio_couleur3", - "srg_c3": "COULEUR 3", - "srg_aod_prid": "3262367" - }, - "analyticsMetadata": { - "media_episode_id": "3262367", - "media_show_id": "11562086", - "media_show": "Oui Mais Non", - "media_episode": "Couleur 3 en direct", - "media_is_livestream": "true", - "media_full_length": "full", - "media_enterprise_units": "RTS", - "media_joker1": "live", - "media_joker2": "rts.ch_audio_couleur3", - "media_joker3": "COULEUR 3", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_thumbnail": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9/scale/width/344", - "media_publication_date": "2011-07-11", - "media_publication_time": "14:20:07", - "media_publication_datetime": "2011-07-11T14:20:07+02:00", - "media_tv_date": "2011-07-11", - "media_tv_time": "14:20:07", - "media_tv_datetime": "2011-07-11T14:20:07+02:00", - "media_content_group": "Couleur 3", - "media_channel_id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "media_channel_cs": "0867", - "media_channel_name": "Couleur 3", - "media_since_publication_d": "4322", - "media_since_publication_h": "103747" - } - }, - { - "chapterUrn": "urn:rts:video:6820736", - "episode": { - "id": "6703608", - "title": "Le 19h30", - "publishedDate": "2015-05-28T19:30:00+02:00", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820735.image/16x9", - "imageTitle": "Le 19h30 [RTS]" - }, - "show": { - "id": "105932", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:show:tv:105932", - "title": "19h30", - "lead": "L'édition du soir du téléjournal.", - "imageUrl": "https://www.rts.ch/2019/08/28/11/33/10667272.image/16x9", - "imageTitle": "RTS Info - Le 19h30, avec nouveau logo RTS Info (la mise en ligne le lundi 26 août 2019) [RTS]", - "bannerImageUrl": "https://www.rts.ch/2019/08/28/11/33/10667272.image/3x1", - "posterImageUrl": "https://www.rts.ch/2021/08/05/18/12/12396566.image/2x3", - "posterImageIsFallbackUrl": false, - "primaryChannelId": "143932a79bb5a123a646b68b1d1188d7ae493e5b", - "primaryChannelUrn": "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b", - "availableAudioLanguageList": [ - { - "locale": "fr", - "language": "Français" - } - ], - "availableVideoQualityList": [ - "SD", - "HD" - ], - "audioDescriptionAvailable": false, - "subtitlesAvailable": true, - "multiAudioLanguagesAvailable": false, - "topicList": [ - { - "id": "908", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:908", - "title": "19h30" - }, - { - "id": "904", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:904", - "title": "Vidéos" - }, - { - "id": "665", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:665", - "title": "Info" - } - ], - "allowIndexing": false - }, - "channel": { - "id": "143932a79bb5a123a646b68b1d1188d7ae493e5b", - "vendor": "RTS", - "urn": "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b", - "title": "RTS 1", - "imageUrl": "https://www.rts.ch/2019/08/28/11/33/10667272.image/16x9", - "imageUrlRaw": "https://il.srgssr.ch/image-service/dynamic/8eebe5.svg", - "imageTitle": "RTS Info - Le 19h30, avec nouveau logo RTS Info (la mise en ligne le lundi 26 août 2019) [RTS]", - "transmission": "TV" - }, - "chapterList": [ - { - "id": "6820736", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820736", - "title": "Le 19h30", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820735.image/16x9", - "imageTitle": "Le 19h30 [RTS]", - "type": "EPISODE", - "date": "2015-05-28T19:30:00+02:00", - "duration": 1897960, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "socialCountList": [ - { - "key": "srgView", - "value": 4731 - }, - { - "key": "srgLike", - "value": 0 - }, - { - "key": "fbShare", - "value": 4 - }, - { - "key": "twitterShare", - "value": 1 - }, - { - "key": "googleShare", - "value": 0 - }, - { - "key": "whatsAppShare", - "value": 1 - } - ], - "displayable": true, - "position": 0, - "noEmbed": false, - "analyticsData": { - "ns_st_ep": "Le 19h30", - "ns_st_ty": "Video", - "ns_st_ci": "6820736", - "ns_st_el": "1897960", - "ns_st_cl": "1897960", - "ns_st_sl": "1897960", - "srg_mgeobl": "false", - "ns_st_tp": "12", - "ns_st_cn": "1", - "ns_st_ct": "vc12", - "ns_st_pn": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc" - }, - "analyticsMetadata": { - "media_segment": "Le 19h30", - "media_type": "Video", - "media_segment_id": "6820736", - "media_episode_length": "1898", - "media_segment_length": "1898", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "12", - "media_duration_category": "long", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820736" - }, - "eventData": "$35972c05ea85ec0d$1096eaf8d14e46b93b68493a1eb04f82b572de863dbcd5e21a05d5da2b8fb4067c33aa0fb0b8e4fc268fbc0e79b81da7ff672aab0df30aa010068e4af06479cd74dc44a935a85aa90d4b7645bce56033a2bb43fd7541aea064a7dd955fbb26d7d11d05b93b91c91e4f6c52d669beda436c9512336065aba0606a14147766aefc1133c4f082a561abea722fb48fa5131b00d0c4a3739533969bc16a812df9172241f76ad7124db467dc988aaac6660bcc942c722bed902a97c5d6c489d9879b334c2cbe89d70784dcd188591ef0e9f2cab5de79d54fa54ec9e291cdd67bf91b1ebbdde8e9a1a10df8549e5abd3f4fafef5adfba535c8ea5d8ee12d41e8e293b2374416b44c9b53eb2c9effde399f7fd0797040ccbecfb2200e519bacc0f90fbf9799369cbb48222acc6f243d665209ce1e19ef4d4cb670333139fd1bd3a16191f3faa8ef35abd3d4e87f16d7554b2779abbe3ced7eb059aca7efe880d583081a769cb6229c8b012d8db66c2e9ef79b2bc", - "resourceList": [ - { - "url": "https://rts-vod-amd.akamaized.net/ww/6820736/44612b73-9114-3968-b712-472a2d10cbad/master.m3u8", - "quality": "HD", - "protocol": "HLS", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": false, - "live": false, - "mediaContainer": "FMP4", - "audioCodec": "AAC", - "videoCodec": "H264", - "tokenType": "NONE", - "audioTrackList": [ - { - "locale": "fr", - "language": "Français", - "source": "HLS" - } - ], - "subtitleInformationList": [ - { - "locale": "fr", - "language": "Français (SDH)", - "source": "HLS", - "type": "SDH" - } - ], - "analyticsData": { - "srg_mqual": "HD", - "srg_mpres": "DEFAULT" - }, - "analyticsMetadata": { - "media_streaming_quality": "HD", - "media_special_format": "DEFAULT", - "media_url": "https://rts-vod-amd.akamaized.net/ww/6820736/44612b73-9114-3968-b712-472a2d10cbad/master.m3u8" - } - } - ], - "segmentList": [ - { - "id": "6820712", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820712", - "title": "FIFA: Sepp Blatter se veut distant des corrompus présumés", - "description": "Le président de l'organisation s'est exprimé et a affirmé ne pas pouvoir \"surveiller tout le monde\".", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820705.image/16x9", - "imageTitle": "FIFA: Sepp Blatter se veut distant des corrompus présumés [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 100200, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 1, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: Sepp Blatter se veut distant des corrompus présumés", - "media_type": "Video", - "media_segment_id": "6820712", - "media_episode_length": "1898", - "media_segment_length": "100", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820712" - }, - "eventData": "$71688688204e7d09$9c0f5dd7f150d9f99795006a27880cc17b0ab91a0e2e3fdb73192f74a16e73f3bdb7815f9d45fd71a84cfb497606dd6f84da95013d0aa37b798ef97b25ce032b5ac23ec4f97b7b75b706e060a7e2c0219687cf0a77010b1ff58cccaf66b639dba8dc3087428271cc1fcc0dfc00c0262d586c1fe676f85b41a600ab94da981051a43313905a212fef157dacf7b373465fe5715073d4b35cd56fbbf4c7a621433114a1b93a65109f8a09055ee6a492f3605d8f001297cbe1eaa6e237bc1ccf3f802f903b427a1f7728e9862fb8b03011aba8e58562a4e55da17bd19d934f9a62a32eb13ebaff59848d93e2097dcc5c4fc2b511c9f23baf152f6ddde4f9bd6339dacfa77df2b59f52aa3b8545d6acb7ea6ee64e8517d407ef9416be9cfe86d2b5c5255d2e88777067cde8794444b9619a0cf93b0196d011eaff6aabaf9af402e901d69ef0fedfa04ed3c5e366ea96f2a9d7edf3c9bcd079a04808a65342bfb4439ee4d7c5ffba9989682205c0fc3eeeb334", - "markIn": 111320, - "markOut": 211520 - }, - { - "id": "6820720", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820720", - "title": "FIFA: nombreux souhaitent que Blatter ne soit pas réélu", - "description": "Ces élections présidentielles, qui opposent le prince Ali et le Haut-Valaisan, aura lieu à la date prévue malgré tout.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820719.image/16x9", - "imageTitle": "FIFA: nombreux souhaitent que Blatter ne soit pas réélu [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 138720, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 2, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: nombreux souhaitent que Blatter ne soit pas réélu", - "media_type": "Video", - "media_segment_id": "6820720", - "media_episode_length": "1898", - "media_segment_length": "139", - "media_number_of_segment_selected": "2", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820720" - }, - "eventData": "$ed7abc075aac5be8$70d739558987bce0b0491f7d7b41273f7daa9711bb3cae813afb17f85d342e848ad8d2154f43d27e3e72c07d147e9e8e96940c839f4f77b0256f5edceaa04e3f32bc96a091e05846ad7ae4ef7041e7ea37a7bda91d66640b3b0d7afa14e3c62395f3f9afde40a661fc67bd8aa527d99b14907bdbccb5f32389b833d23a77e89558ed1beee4cf8cbada5851208358e1de03b5ccbc0ae21f15e0bfd7a2e295ccd12cb9949b457b1c16e17b8f93eba30cb464e4374ae6cbae72e2b47da1f912548d6414347e2627a58cb9545e63df6291d8dee16fc0b7611c3436e249f9122090274a77b5e11684f0064b782cfff2a47a18efecbc3b952368f52459b5d09d550a0416ea907ab4e3e94af247f75037bd8d729ce9b14114830cc2af8a47b11437e62ca8ad0121d5e6d9d0cf83b399252a233f616b589c1714419fc601a84c64c16e2a952e89b6c514d4d10a727f6f0e8fb9d171bddf85559905442de0f8d4f3e27e82ff96f4c9c363ce2f06bf79d0c220f747", - "markIn": 211560, - "markOut": 350280 - }, - { - "id": "6820714", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820714", - "title": "FIFA: la justice américaine estime que la corruption règne depuis des années", - "description": "Certains accusés se seraient livrés au FBI, révélant alors les diverses fraudes soupçonnées.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820713.image/16x9", - "imageTitle": "FIFA: la justice américaine estime que la corruption règne depuis des années [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 136960, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 3, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: la justice américaine estime que la corruption règne depuis des années", - "media_type": "Video", - "media_segment_id": "6820714", - "media_episode_length": "1898", - "media_segment_length": "137", - "media_number_of_segment_selected": "3", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820714" - }, - "eventData": "$744ed4db0b4cd83c$28da18646dea2bbefc7028f4ad6e3e46472924dda8f00a0558346509fc7190ea5052cf2f74ea34b012634feea17c18169124103dbc5f22b1fc628d29a2a4ca6efdce18efe7ab563adaa8e0c3d52ce1ecdf0ed915a5480421cabc2d2038f412a47b8959f782b704165559c191fce533101e6ef706e8817b07b8bbb35796e836a0a4da3699d669e915801ea879ec04b47eb27a6f7dbb918fecf3f3f53f45370396650fcac161d451a0ec129357b42f704d4dd88f7612e2294cda85621e1980b63e3e1b72b58b5f2336a8b5640738f949261859799bd44080689761fcd62d785aa3fdf4e17bdbc6cf238e66cff9033ae686f1adff97d7c7fe840b8a3876bc3101b0f07017ca15a1efaf8b59ce0d3b5eab93cbd737c9f556615a29acf17ff251c9505f28c0ea504fe827ff01036100066aebb5022665ff261b9bf5899be548889d1fcadcdc1f810e62b040729dedf2a74748837c3f7ef7a07579a6ba3e5c6c1a898c1260eac4de000ea867bf017c86c8dd42", - "markIn": 350320, - "markOut": 487280 - }, - { - "id": "6820726", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820726", - "title": "FIFA: Sepp Blatter règne seul depuis 17 ans", - "description": "Selon certains, le Suisse a su gagner la fidélité de beaucoup de fédérations de football, grâce auxquelles il a toujours gagné les élections.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820725.image/16x9", - "imageTitle": "FIFA: Sepp Blatter règne seul depuis 17 ans [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 136200, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 4, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: Sepp Blatter règne seul depuis 17 ans", - "media_type": "Video", - "media_segment_id": "6820726", - "media_episode_length": "1898", - "media_segment_length": "136", - "media_number_of_segment_selected": "4", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820726" - }, - "eventData": "$d355e2b7031e11af$3c6c3c6585841c69ca49c5f51f98b04bdf8e711be2fb5b72f13ba0efc9249a41d587548c64243e1ae12cde8a03a89cd7a492be7e1601be88f253bdfb1d424cf231f795880901197359ffd61917655814135abf809bc5bf46a35f51fa1561c0fd8eebed8497001a881365cbcd026258f2a20a3166551db8037c8876879605a21a9d19634a6d59b28a3663170bb9102c78d15746aee48c6b50776ba7157f654bdff3f53f95ddaff83a4eeb703cf0f202f4e4d5dad521ac93a04d6d92da8d623ad12cb6cb6059ff5ff540a381e57cd06ab47a4dcf84ddf8a9fb2b35f6be88ad6fdf7987b3cd135206908102e8905d2695939ce3d3b73ff9daa4a917fedbf17bdbc89fc42195042c202ae26731310d297f1b1ce91c46549eee3c316909474280d571e97484d71439cb55307fce45481cc6b383fe7e1a8cdc8bd09ed8284ba4cad8bd767605db1bcde959387ac2d0a29860af68f68ec33552ba5f4c9f78ce3fb525bc15a21061d3717e44b8e355a0c57e7a2e", - "markIn": 550720, - "markOut": 686920 - }, - { - "id": "6820728", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820728", - "title": "FIFA: l'image de la Suisse pourrait être ternie", - "description": "Le pays a peut être été trop clément en matière d'exonération fiscale ou de lutte anti-corruption.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820727.image/16x9", - "imageTitle": "FIFA: l'image de la Suisse pourrait être ternie [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 117000, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 5, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: l'image de la Suisse pourrait être ternie", - "media_type": "Video", - "media_segment_id": "6820728", - "media_episode_length": "1898", - "media_segment_length": "117", - "media_number_of_segment_selected": "5", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820728" - }, - "eventData": "$60a4f9c29b9364a6$f129372d1d920bf271d9a019a5539747c7fc6c1b34b631d428d0ca6504a633cda9b2e7c0506d3908ecbb67b3db817cc8d1824a4198ee9798242dab751a9334f8bf933535bb658d80712b08bbd1eb802c76cac516721191d4d536e369ac91dce8bdf89f213b7e688f7d34b3416a380df3511c364805316cd30f011490f0e9a7e885c04bd477a80d1991562e5ed9b23316cf75a442282d79f3913b209b047053272b43cf3e7e800082e4a67ec3dafc7220acf81163c1d3c6c16a9993e36f92ba3f6c1605e138ee7109adb98a7272f434be1f28b5df56384998848b7a90cfe0e8e8f9a12fef48bd1bc34d12bac33e03a1bf838151e6257476945ebaa706dba3eeca5d3ebfcef15142b563d4091d66f4e5bdd6ef9fb72a15b15cd3199eb752622c4e02441664825cab08ba72fd3f05101919832f8fdd493890a9c4b10ddd2b5e28448537ebb70e33df65cdcbeca997597e5e3abc40a9ebe2be5510b2c0cea2f7ae6ce2ec0e7f074eb75bae0a44ab8ad4a0d7", - "markIn": 709480, - "markOut": 826480 - }, - { - "id": "6820734", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820734", - "title": "FIFA: le point avec Pierre-Alain Dupuis, à Zurich", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820733.image/16x9", - "imageTitle": "FIFA: le point avec Pierre-Alain Dupuis, à Zurich [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 236720, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 6, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "FIFA: le point avec Pierre-Alain Dupuis, à Zurich", - "media_type": "Video", - "media_segment_id": "6820734", - "media_episode_length": "1898", - "media_segment_length": "237", - "media_number_of_segment_selected": "6", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820734" - }, - "eventData": "$1539564dda06e4ca$ee4960ade603b9e2b75f143d82232eff1e9863e26dfc18c33b15ff6901b136068da389ab2c99ef755b15f659ece03ee53df4075829c5f3443998711c251245fd59af3a1dcf3323fe40d7bb78c83ad6a6cac42daf2f6fff98affd7a016ced32c09f752cdb5114e34d28091395d54b2ccd0aca5aa79e395b06e31f498271f3e24ac8ec0d0db2d58404e0517e1f4580a511375a9bac52777b480624a2e0750e9c1aad5b433ada350b68fc27f4f6e29d0b15b09bbe02d41bdc263ac7f832d125be2e1fa093de75290258fa605c3d4ab3f708a79dd0c3f145c265b4e1233efaf0d5e8e1cfe5c5a1e4bdb161b6026d7881f509844ad4b9f4c9673c8a22c0d2a3eb51b1c82d057a5502a6eb6843674506fc0238001968dfd2db8ea2713bfa9eb1062e2270f26982088460d85df916a3c1b6070c236e4063465710f378a7ec7c42173b7b634e4eb00f17f730769ca79bcd7fe604b86364edbbb718a1d49b39d27f928a617012878bed166315069b914cd6b43b87", - "markIn": 826520, - "markOut": 1063240 - }, - { - "id": "6820718", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820718", - "title": "Votations: le diagnostic préimplantatoire est décrié par Insieme", - "description": "L'association craint que le DPI dissuade davantage de parents de donner naissance à un enfant handicapé.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820717.image/16x9", - "imageTitle": "Votations: le diagnostic préimplantatoire est décrié par Insieme [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 123480, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 7, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Votations: le diagnostic préimplantatoire est décrié par Insieme", - "media_type": "Video", - "media_segment_id": "6820718", - "media_episode_length": "1898", - "media_segment_length": "123", - "media_number_of_segment_selected": "7", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820718" - }, - "eventData": "$ade26625e0fe6525$60f00fe8d073893fb0cbfc03fd419d0100e81dd197ecc2efb6c2380424b1e28c66bec056cedc009dbe96e9e23c2846b22de4151f70eb52f2e720b9a2f41fbbf20a6ed49ba9bb714458c644affe662740812b6262180981a5d613629e8152b6ffaa90c45a70af69d13312574da70557f454f5acd9aa44ec7521041239bcc936c0f790fc4cea8cf59ccf780a35817698dc91fb11f4f956740fcb71fe1a44faaaa5156f296f7acb4656c19ccd6a1a521834f0d79cc73b089f43cd59b3decf644590653f06e01737a01eebf33818322173093bcffef561cc5e966f4a40fb71bb32741c0b239f7ff5f96f0e6de61eb29ad5a77ef792d0ab497c31b30af429b529395a74811003a5209852e7cf8719d7041d554c0ad3355280f0348a5d1653fc254c3ed81b18ae757bc6bc7a56ea0743585e69ba747fbc11f1700c8032c78943ff71dc2e47a7fa7567f0120b69a288917c6af0968e9274ede26bd71e16ae1550182c7851370932892af87c76e053ad46f23e65", - "markIn": 1063520, - "markOut": 1187000 - }, - { - "id": "6820724", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820724", - "title": "Votations / DPI: les explications d'Amélie Boguet", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820723.image/16x9", - "imageTitle": "Votations - DPI: les explications d'Amélie Boguet [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 81000, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 8, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Votations / DPI: les explications d'Amélie Boguet", - "media_type": "Video", - "media_segment_id": "6820724", - "media_episode_length": "1898", - "media_segment_length": "81", - "media_number_of_segment_selected": "8", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820724" - }, - "eventData": "$a1943a612153bfc3$4a806744914b9a5ee3cd4ec1ab701ef362fb2d1f257ebbd7c93627f3304f6e7cfc882a9db30abecd1da7f10fdf1dd1bc6d0a92d28da7be5a4bd7f842c616873ce280952e44af59fc19f2cf3d9b09dcdfa460b00eb6cf8bd86b72939f9edbfd2f347ac94b53d68a480b98f6fc94434aa45a1d9cbb34b22306d983e486b5f551dfaedf0e761ca7d052c475c3541d49aa5eed76eb040d1223e63d9a472954b084b7aa32ebb086d3ec5980875bad845204b02beac844f4c2230ce0c661f3ea036466374300aef4433cf29cba23611d0229c217d131c6714afa8647b7ee4157b300a4fc090fdef0c623c58fac5a7c995498cb231d772d2aa157e8f03ad709df4e1f8eba05fc1732a4c3a306c88fd96a50652f54ccb13eff83a61dc52486a9aecb93288437a31bd59cad27e16b28c990b1d5cfeb377fcc8446195acc73481f665c69cf8c7fdbb4dd43a11cfcf3f8e2c66a7a4b75e2c9852cbd7c94f96cb4f95b3b650fbc5697615fb7d50d20e1dbda9ebac91c", - "markIn": 1187320, - "markOut": 1268320 - }, - { - "id": "6820732", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820732", - "title": "Votations / DPI: l'interdiction du diagnostic nuit à certains couples", - "description": "Certains parents ou futurs parents voyagent alors pour consulter, ce qui n'est pas toujours évident selon les ressources du couple.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820731.image/16x9", - "imageTitle": "Votations - DPI: l'interdiction du diagnostic nuit à certains couples [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 175800, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 9, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Votations / DPI: l'interdiction du diagnostic nuit à certains couples", - "media_type": "Video", - "media_segment_id": "6820732", - "media_episode_length": "1898", - "media_segment_length": "176", - "media_number_of_segment_selected": "9", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820732" - }, - "eventData": "$cba09d3c27881ad8$be2815de766b8df541dac5848a814e4a6cf12c597331f8d22964c8d16af6cf956d9d4e7ba103b7d26072d90315421bc56a02f67d5fe16d2261fd182e0da59c0c84291b0c3380a497f838796977eab366f218fb54871875beea9eeaf8332a0ca1439bc7960b3caac1efabab403a06cf6d77c3a98f4ff317eb1bb70d55d848331578a59865ce68c9eacc8411243bbb8abe42c0c49ba5b682d8e3ce38753b01f7d8da2290d1ccc104a7cde523cbef657786c1225a62f8ba4b7e8b8d6392e487b4d31d8e232f62adcf0802153fe264dbfb610391cac494d5a88a0703cc49e3a1a52267973f9360ddee81bb9e718b82d6df5ffee9f336e22fe9d6116d36ed55867ea7f10f508ab93cf88804d805b8be3074943d2fa80d596ed88fa9bc8971ba8d9283c071f07c9a1c7720bffd22602474692c6cdbd0e23942e105a4e8c1ead3643ebe3a5a8627587f613fc9e71ded543c471c6271f659eec59974c35c27a0af634ef8993463ad9e9b698a1c1943f8f979b6d2", - "markIn": 1268360, - "markOut": 1444160 - }, - { - "id": "6820722", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820722", - "title": "GE: un géant chinois de la pharma, Tasly, s'installe", - "description": "Plusieurs dizaines d'emplois vont être créés et une usine pourrait alors voir le jour dans le canton de Fribourg.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820721.image/16x9", - "imageTitle": "GE: un géant chinois de la pharma, Tasly, s'installe [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 125040, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 10, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "GE: un géant chinois de la pharma, Tasly, s'installe", - "media_type": "Video", - "media_segment_id": "6820722", - "media_episode_length": "1898", - "media_segment_length": "125", - "media_number_of_segment_selected": "10", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820722" - }, - "eventData": "$9749fae13beede7f$ab571d879779715a84afcb18c19aa9e9705f19fceeb529f87830ec4d5eb59e0a2c5e27f879d2ed70c5052f051d840f86c935ffb7867cd16ce453c5c3947d72dfd61dfba2c41c44ec3ea8083e96ecf3c5416578245d2dfdf1951f9cd10f801df43291a941ef45bc9a2559b1b27c8cfa731e9876c5eb19701656450c0f2291f29c3550e2387191b8d2a2fc10fbe2b8224b8a5a1dfa5252404bd16ba07eed855287bc6edd5939280c2e7543a9895e003606b1c55bf13d80c6130797c2d678dcf99df16c0f9905a85e5f6786a2d48adf67ad15ac21b605be2cf4c978762fff550503d3d603003172881ad96060fe4e1b916a62f754b0914aad7af54e6eb08260ea9f109cd6cbc669895930b02ea906fd06d609342d1fb51a7f2a6c961d1972b77dc5ff0dd9e11005696597f4eb614e3b13ef3235232a5b1e31bb82ba47721f6b6d1d45c5ba0b543e91dc992589b90f9e7586b9ad996dea6a5a0497516b0f1bcaf19fbeb2d9fd1ba1f0f132960e17d467eda5", - "markIn": 1444200, - "markOut": 1569240 - }, - { - "id": "6820716", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820716", - "title": "Le planning familial vient en aide depuis 50 ans", - "description": "Les jeunes profitent de ce service qui contribue à promouvoir la qualité de vie dans les différentes étapes de la vie affective.", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820715.image/16x9", - "imageTitle": "Le planning familial vient en aide depuis 50 ans [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 120080, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 11, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Le planning familial vient en aide depuis 50 ans", - "media_type": "Video", - "media_segment_id": "6820716", - "media_episode_length": "1898", - "media_segment_length": "120", - "media_number_of_segment_selected": "11", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820716" - }, - "eventData": "$11797d93867fbbff$38abb41acc9aefc030d92b0c08a30ac4dd99bfd90c5d79ee70625b0519aa4477bc180bdab31ffdbab5e4fed3fd19a19ace1d75ce945fed9b1c8293749a0c6dece33ccea0e5f3dcfb290fa9980c3dcb5e287993b0a5a21d6b74f6e1656484beb85f02765a8947c4dd2da0d43cd6749504af6cb41db825c800a66e5170d9cf0f50a0f01de3ca2e122809975571305a9ce819378902e5418dbb1c0d433d422fb1658df6fc50a1e8bcb32761a0c93fde9d133b3272e1cac063deee68b5b5e22ed9da396aea852d80bf12bec5a5f414a2ad8978dcc64cc26baa748eebb59a1eab8f68ae5dc68c9cc38d7444562727400f3d0a3261ce910630dd411d016be0a00f5c3f6a868b90cc276eef092309ce74582eeaf9117c83bf17cbea217b254f556fb8900d68188b090ee17efe70cef56c1df2c7bd43e87096db7b6f9d4e4eeab02ec8bc47bc2047cde332aaf0483eccdf43e04a99e63f58c9cc689a4505e3cefd858fe2dbe868c765bffd302e9ed9092aec85df", - "markIn": 1569280, - "markOut": 1689360 - }, - { - "id": "6820730", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:6820730", - "title": "Musique: les choeurs d'enfants restent une contribution courante", - "description": "Comme d'autres avant lui, Raphael s'est alors entouré d'enfants pour son nouvel album \"Somnambule\".", - "imageUrl": "https://www.rts.ch/2015/05/28/20/19/6820729.image/16x9", - "imageTitle": "Musique: les choeurs d'enfants restent une contribution courante [RTS]", - "type": "CLIP", - "date": "2015-05-28T19:30:00+02:00", - "duration": 154040, - "validFrom": "2015-05-28T20:01:00+02:00", - "playableAbroad": true, - "displayable": true, - "fullLengthUrn": "urn:rts:video:6820736", - "position": 12, - "noEmbed": false, - "analyticsMetadata": { - "media_segment": "Musique: les choeurs d'enfants restent une contribution courante", - "media_type": "Video", - "media_segment_id": "6820730", - "media_episode_length": "1898", - "media_segment_length": "154", - "media_number_of_segment_selected": "12", - "media_number_of_segments_total": "12", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:6820730" - }, - "eventData": "$e1ebe74c015c0508$b2b03bca04a0e4690733fdf7f37fafef858a41cfbb4405711f2c476c80be7c65979f60c6db608134bb27c42cd3f135f43afd8e1a711595119196d12efd4234071030d272a1bd444a29a78c719fd1218127d5f2f5f6a1d8df9ce3d9844cca732d3e67bcc9dd09427d7a22da6ab3762d2a91cd95eaa0046c644005e09d1f8497ec18b768354968eeae241d0463b8c6220319518cf91e9285aecf41955054038cd5fb5ec6127eab5ba39c556f1a08684eabe561e4892d75cc3e49ff2cbde8b14d3c1df27899ab3db3fd34eb2294420652bdcf713e3763ea544ff7102d29210c9f26b0aa073cc133777d13713e693c8f7e845421ab6b4e7978c6b1d1fc2793610b17f098650a63ad2f5494cd3c42743188dbf4970fede30e50e1b0f58a6efa4cef80fa82e68eeaefc5f8791f07b3ae12dc1a9d0f9c85a8e390d2f060e263cee964333843ff8a2ce7df9f5952678be4ae1fada33a915a454987ae2119a91c3ecfbdb17492268e2e116464e2dffda472ee814f", - "markIn": 1689400, - "markOut": 1843440 - } - ], - "aspectRatio": "16:9" - } - ], - "topicList": [ - { - "id": "908", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:908", - "title": "19h30" - }, - { - "id": "904", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:904", - "title": "Vidéos" - }, - { - "id": "665", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:665", - "title": "Info" - } - ], - "analyticsData": { - "srg_pr_id": "6703608", - "srg_plid": "105932", - "ns_st_pl": "19h30", - "ns_st_pr": "19h30 du 28.05.2015", - "ns_st_dt": "2015-05-28", - "ns_st_ddt": "2015-05-28", - "ns_st_tdt": "2015-05-28", - "ns_st_tm": "19:30:00", - "ns_st_tep": "f_858979", - "ns_st_li": "0", - "ns_st_stc": "0867", - "ns_st_st": "RTS Online", - "ns_st_tpr": "105932", - "ns_st_en": "*null", - "ns_st_ge": "*null", - "ns_st_ia": "*null", - "ns_st_ce": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc", - "srg_unit": "RTS", - "srg_c1": "full", - "srg_c2": "video_info_journal-19h30", - "srg_c3": "RTS 1", - "srg_tv_id": "f_858979" - }, - "analyticsMetadata": { - "media_episode_id": "6703608", - "media_show_id": "105932", - "media_show": "19h30", - "media_episode": "19h30 du 28.05.2015", - "media_is_livestream": "false", - "media_full_length": "full", - "media_enterprise_units": "RTS", - "media_joker1": "full", - "media_joker2": "video_info_journal-19h30", - "media_joker3": "RTS 1", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_tv_id": "f_858979", - "media_thumbnail": "https://www.rts.ch/2015/05/28/20/19/6820735.image/16x9/scale/width/344", - "media_publication_date": "2015-05-28", - "media_publication_time": "20:01:00", - "media_publication_datetime": "2015-05-28T20:01:00+02:00", - "media_tv_date": "2015-05-28", - "media_tv_time": "19:30:00", - "media_tv_datetime": "2015-05-28T19:30:00+02:00", - "media_content_group": "19h30,Vidéos,Info", - "media_channel_id": "143932a79bb5a123a646b68b1d1188d7ae493e5b", - "media_channel_cs": "0867", - "media_channel_name": "RTS 1", - "media_since_publication_d": "2905", - "media_since_publication_h": "69733" - } - }, - { - "chapterUrn": "urn:rts:video:8841634", - "episode": { - "id": "8741989", - "title": "Couleur 3 en direct", - "publishedDate": "2017-01-14T15:08:55+01:00", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3" - }, - "show": { - "id": "8483936", - "vendor": "RTS", - "transmission": "RADIO", - "urn": "urn:rts:show:radio:8483936", - "title": "Couleur 3 en vidéos", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "bannerImageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/3x1", - "posterImageUrl": "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg", - "posterImageIsFallbackUrl": true, - "primaryChannelId": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "primaryChannelUrn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "audioDescriptionAvailable": false, - "subtitlesAvailable": false, - "multiAudioLanguagesAvailable": false, - "topicList": [ - { - "id": "16208", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:16208", - "title": "Couleur 3" - } - ], - "allowIndexing": false - }, - "channel": { - "id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "vendor": "RTS", - "urn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "title": "Couleur 3", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "transmission": "RADIO" - }, - "chapterList": [ - { - "id": "8841634", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:8841634", - "title": "Couleur 3 en direct", - "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", - "imageTitle": "Chaîne Couleur 3", - "type": "LIVESTREAM", - "date": "2017-01-14T15:08:55+01:00", - "duration": 0, - "playableAbroad": true, - "displayable": true, - "position": 0, - "noEmbed": false, - "analyticsData": { - "ns_st_ep": "Livestream", - "ns_st_ty": "Video", - "ns_st_ci": "8841634", - "ns_st_el": "0", - "ns_st_cl": "0", - "ns_st_sl": "0", - "srg_mgeobl": "false", - "ns_st_tp": "1", - "ns_st_cn": "1", - "ns_st_ct": "vc13", - "ns_st_pn": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc" - }, - "analyticsMetadata": { - "media_segment": "Livestream", - "media_type": "Video", - "media_segment_id": "8841634", - "media_episode_length": "0", - "media_segment_length": "0", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "1", - "media_duration_category": "infinit.livestream", - "media_is_geoblocked": "false", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_urn": "urn:rts:video:8841634" - }, - "fullLengthMarkIn": 0, - "fullLengthMarkOut": 0, - "resourceList": [ - { - "url": "https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8?dw=0", - "quality": "HD", - "protocol": "HLS", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": false, - "live": true, - "mediaContainer": "MPEG2_TS", - "audioCodec": "AAC", - "videoCodec": "H264", - "tokenType": "NONE", - "analyticsData": { - "srg_mqual": "HD", - "srg_mpres": "DEFAULT" - }, - "analyticsMetadata": { - "media_streaming_quality": "HD", - "media_special_format": "DEFAULT", - "media_url": "https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8?dw=0" - } - } - ], - "aspectRatio": "16:9" - } - ], - "topicList": [ - { - "id": "16208", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:16208", - "title": "Couleur 3" - } - ], - "analyticsData": { - "srg_pr_id": "8741989", - "srg_plid": "8483936", - "ns_st_pl": "Livestream", - "ns_st_pr": "Couleur 3 en direct", - "ns_st_dt": "2017-01-14", - "ns_st_ddt": "2017-01-14", - "ns_st_tdt": "2017-01-14", - "ns_st_tm": "15:08:55", - "ns_st_tep": "*null", - "ns_st_li": "1", - "ns_st_stc": "0867", - "ns_st_st": "Couleur 3", - "ns_st_tpr": "11562086", - "ns_st_en": "*null", - "ns_st_ge": "*null", - "ns_st_ia": "*null", - "ns_st_ce": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc", - "srg_unit": "RTS", - "srg_c1": "live", - "srg_c2": "rts.ch_video_couleur3", - "srg_c3": "COULEUR 3", - "srg_tv_id": "3f1e4c4e-0f1e-479b-92b7-16a9f064a2e3" - }, - "analyticsMetadata": { - "media_episode_id": "8741989", - "media_show_id": "11562086", - "media_show": "Oui Mais Non", - "media_episode": "Couleur 3 en direct", - "media_is_livestream": "true", - "media_full_length": "full", - "media_enterprise_units": "RTS", - "media_joker1": "live", - "media_joker2": "rts.ch_video_couleur3", - "media_joker3": "COULEUR 3", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_thumbnail": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9/scale/width/344", - "media_publication_date": "2017-01-14", - "media_publication_time": "15:08:55", - "media_publication_datetime": "2017-01-14T15:08:55+01:00", - "media_tv_date": "2017-01-14", - "media_tv_time": "15:08:55", - "media_tv_datetime": "2017-01-14T15:08:55+01:00", - "media_content_group": "Couleur 3", - "media_channel_id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", - "media_channel_cs": "0867", - "media_channel_name": "Couleur 3", - "media_since_publication_d": "2308", - "media_since_publication_h": "55409" - } - }, - { - "chapterUrn": "urn:rts:video:13444428", - "episode": { - "id": "13444410", - "title": "RENCA", - "publishedDate": "2022-10-06T16:58:00+02:00", - "imageUrl": "https://www.rts.ch/2022/10/06/17/32/13444418.image/4x5", - "imageTitle": "08 Outro [RTS]" - }, - "show": { - "id": "12698364", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:show:tv:12698364", - "title": "Le rencard", - "imageUrl": "https://ws.srf.ch/asset/image/audio/8b32fd87-459e-40f1-9519-ba0cbb01f34e/NOT_SPECIFIED.jpg", - "posterImageUrl": "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg", - "posterImageIsFallbackUrl": true, - "audioDescriptionAvailable": false, - "subtitlesAvailable": false, - "multiAudioLanguagesAvailable": false, - "topicList": [ - { - "id": "68210", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:68210", - "title": "Le rencard" - }, - { - "id": "2451", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:2451", - "title": "Émissions" - } - ], - "allowIndexing": false - }, - "chapterList": [ - { - "id": "13444428", - "mediaType": "VIDEO", - "vendor": "RTS", - "urn": "urn:rts:video:13444428", - "title": "08 Outro", - "imageUrl": "https://www.rts.ch/2022/10/06/17/32/13444418.image/4x5", - "imageTitle": "08 Outro [RTS]", - "type": "EPISODE", - "date": "2022-10-06T16:58:00+02:00", - "duration": 7320, - "validFrom": "2022-10-06T16:58:00+02:00", - "playableAbroad": true, - "socialCountList": [ - { - "key": "srgView", - "value": 278 - }, - { - "key": "srgLike", - "value": 0 - }, - { - "key": "fbShare", - "value": 0 - }, - { - "key": "twitterShare", - "value": 0 - }, - { - "key": "googleShare", - "value": 0 - }, - { - "key": "whatsAppShare", - "value": 0 - } - ], - "displayable": true, - "position": 0, - "noEmbed": false, - "analyticsData": { - "ns_st_ep": "08 Outro", - "ns_st_ty": "Video", - "ns_st_ci": "13444428", - "ns_st_el": "7320", - "ns_st_cl": "7320", - "ns_st_sl": "7320", - "srg_mgeobl": "false", - "ns_st_tp": "1", - "ns_st_cn": "1", - "ns_st_ct": "vc11", - "ns_st_pn": "1", - "ns_st_cdm": "eo", - "ns_st_cmt": "ec" - }, - "analyticsMetadata": { - "media_segment": "08 Outro", - "media_type": "Video", - "media_segment_id": "13444428", - "media_episode_length": "7", - "media_segment_length": "7", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "1", - "media_duration_category": "short", - "media_is_geoblocked": "false", - "media_is_web_only": "true", - "media_production_source": "produced.for.web", - "media_urn": "urn:rts:video:13444428" - }, - "eventData": "$31560b9cf8eff8a6$53ec475eaefd11e175a15a342b6a0fc3c0007f3bac816d49f9fe53f94ba8363f5977b4f21de60406f24d3f00178694c1b642c984456c298a2d2ac4c330639eb3a0fc1e2db2171ce26fb28ecb6621b17577681bff6de80e73a88a5b73f0ede629c2f529d4b09d9981979fa9ed112a82b06c4c86f2cc8a265b127932b8e9ff0da13a34d6e1199004cf954b20c65670330a9f158493934be068d96c20622212189c09f16d251bd73a6defdac626d899e06d874f00fe6ec1e36ab100a7fc9add7e67c82472868ff61f24091ccb4b4ab7489601b61e2505456c5a51b1f1ec0ebe468b3c87cfecee2a3f0d28dad0f46a1f7b2ec40d5d39dbfffea6950b1bf6971aabfd71fc4dcc39724e972aa2b8f062c27e89d58aaf69830b98b6eb22417e554b7c730dff83367f97b99153afe7c2c6e230cfb8cf5eb62b807c6d352966eaadac5aab", - "resourceList": [ - { - "url": "https://rts-vod-amd.akamaized.net/ww/13444428/857d97ef-0b8e-306e-bf79-3b13e8c901e4/master.m3u8", - "quality": "HD", - "protocol": "HLS", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": false, - "live": false, - "mediaContainer": "FMP4", - "audioCodec": "AAC", - "videoCodec": "H264", - "tokenType": "NONE", - "audioTrackList": [ - { - "locale": "fr", - "language": "Français", - "source": "HLS" - } - ], - "analyticsData": { - "srg_mqual": "HD", - "srg_mpres": "DEFAULT" - }, - "analyticsMetadata": { - "media_streaming_quality": "HD", - "media_special_format": "DEFAULT", - "media_url": "https://rts-vod-amd.akamaized.net/ww/13444428/857d97ef-0b8e-306e-bf79-3b13e8c901e4/master.m3u8" - } - } - ], - "aspectRatio": "9:16" - } - ], - "topicList": [ - { - "id": "68210", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:68210", - "title": "Le rencard" - }, - { - "id": "2451", - "vendor": "RTS", - "transmission": "TV", - "urn": "urn:rts:topic:tv:2451", - "title": "Émissions" - } - ], - "analyticsData": { - "srg_pr_id": "13444410", - "srg_plid": "12698364", - "ns_st_pl": "Le rencard", - "ns_st_pr": "Le rencard du 06.10.2022", - "ns_st_dt": "2022-10-06", - "ns_st_ddt": "2022-10-06", - "ns_st_tdt": "*null", - "ns_st_tm": "*null", - "ns_st_tep": "253058342-43898", - "ns_st_li": "0", - "ns_st_stc": "0867", - "ns_st_st": "RTS Online", - "ns_st_tpr": "12698364", - "ns_st_en": "*null", - "ns_st_ge": "*null", - "ns_st_ia": "*null", - "ns_st_ce": "1", - "ns_st_cdm": "eo", - "ns_st_cmt": "ec", - "srg_unit": "RTS", - "srg_c1": "full", - "srg_c2": "video_emissions_le-rencard", - "srg_c3": "RTS.ch", - "srg_tv_id": "253058342-43898" - }, - "analyticsMetadata": { - "media_episode_id": "13444410", - "media_show_id": "12698364", - "media_show": "Le rencard", - "media_episode": "Le rencard du 06.10.2022", - "media_is_livestream": "false", - "media_full_length": "full", - "media_enterprise_units": "RTS", - "media_joker1": "full", - "media_joker2": "video_emissions_le-rencard", - "media_joker3": "RTS.ch", - "media_is_web_only": "true", - "media_production_source": "produced.for.web", - "media_tv_id": "253058342-43898", - "media_thumbnail": "https://www.rts.ch/2022/10/06/17/32/13444418.image/4x5/scale/width/344", - "media_publication_date": "2022-10-06", - "media_publication_time": "16:58:00", - "media_publication_datetime": "2022-10-06T16:58:00+02:00", - "media_content_group": "Le rencard,Émissions", - "media_since_publication_d": "216", - "media_since_publication_h": "5207" - } - }, - { - "chapterUrn": "urn:srf:video:f10ba470-6a3c-4479-8b2a-4529f7066234", - "episode": { - "id": "dcc5b238-7943-4fe7-8636-e41989d91408", - "title": "Tagesschau vom 09.05.2023: Mittagsausgabe", - "publishedDate": "2023-05-09T12:45:00+02:00", - "imageUrl": "https://ws.srf.ch/asset/image/audio/7e18e733-622b-4bd5-9323-971239e49844/WEBVISUAL/1607950376.jpg" - }, - "show": { - "id": "ff969c14-c5a7-44ab-ab72-14d4c9e427a9", - "vendor": "SRF", - "transmission": "TV", - "urn": "urn:srf:show:tv:ff969c14-c5a7-44ab-ab72-14d4c9e427a9", - "title": "Tagesschau", - "lead": "Nationale und internationale Nachrichten vom Tag.", - "description": "Die «Tagesschau» berichtet über Themen aus Politik, Wirtschaft, Kultur, Sport, Gesellschaft und Wissenschaft aus dem In- und Ausland. ", - "imageUrl": "https://ws.srf.ch/asset/image/audio/7e18e733-622b-4bd5-9323-971239e49844/WEBVISUAL/1607950376.jpg", - "imageTitle": "Tagesschau", - "posterImageUrl": "https://ws.srf.ch/asset/image/audio/7e18e733-622b-4bd5-9323-971239e49844/POSTER/1681820057.jpg", - "posterImageIsFallbackUrl": false, - "timeTableUrl": "https://www.srf.ch/programm/tv/mediagroup/ts20", - "links": [ - { - "title": "In Gebärdensprache", - "link": "https://www.srf.ch/play/tv/sendung/tagesschau-in-gebaerdensprache?id=c40bed81-b150-0001-2b5a-1e90e100c1c0" - }, - { - "title": "Tagesschau Spezial", - "link": "https://www.srf.ch/play/tv/sendung/tagesschau-spezial?id=c4b213cd-9790-0001-3063-e4001037ebe0" - }, - { - "title": "SRF Augenzeuge", - "link": "https://www.srf.ch/meteo/uebersicht/zuschauer-bilder-srf-augenzeuge" - } - ], - "primaryChannelId": "23FFBE1B-65CE-4188-ADD2-C724186C2C9F", - "primaryChannelUrn": "urn:srf:channel:tv:23FFBE1B-65CE-4188-ADD2-C724186C2C9F", - "numberOfEpisodes": 17567, - "topicList": [ - { - "id": "a709c610-b275-4c0c-a496-cba304c36712", - "vendor": "SRF", - "transmission": "TV", - "urn": "urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712", - "title": "News", - "lead": "Hier finden Sie alle Sendungen von SRF zum Themenbereich News: aktuell, informativ und tiefgründig.", - "description": "Tagesschau, Meteo, 10vor10, Schweiz aktuell, Börse, Kinder News, Forward - die News-Sendungen von SRF stehen für seriös recherchierte Berichterstattung und kompetente Hintergrundberichterstattung." - } - ] - }, - "channel": { - "id": "23FFBE1B-65CE-4188-ADD2-C724186C2C9F", - "vendor": "SRF", - "urn": "urn:srf:channel:tv:23FFBE1B-65CE-4188-ADD2-C724186C2C9F", - "title": "SRF 1", - "imageUrl": "https://ws.srf.ch/asset/image/audio/d91bbe14-55dd-458c-bc88-963462972687/EPISODE_IMAGE", - "imageUrlRaw": "https://il.srgssr.ch/image-service/dynamic/536ef7.svg", - "imageTitle": "Logo", - "transmission": "TV" - }, - "chapterList": [ - { - "id": "f10ba470-6a3c-4479-8b2a-4529f7066234", - "mediaType": "VIDEO", - "vendor": "SRF", - "urn": "urn:srf:video:f10ba470-6a3c-4479-8b2a-4529f7066234", - "title": "Tagesschau vom 09.05.2023: Mittagsausgabe", - "lead": "CS bleibt vorerst eine eigenständige Bank, Pensionskassen machen 100 Milliarden Franken Verlust, Russland feiert Tag des Sieges in abgespeckter Form", - "imageUrl": "https://ws.srf.ch/asset/image/audio/c7f097f3-2738-4bc1-ae41-244399a3fbc9/EPISODE_IMAGE/1683630053.png", - "imageTitle": "Tagesschau vom 09.05.2023: Mittagsausgabe", - "type": "EPISODE", - "date": "2023-05-09T12:45:00+02:00", - "duration": 712800, - "validFrom": "2023-05-09T12:45:00+02:00", - "playableAbroad": true, - "socialCountList": [ - { - "key": "srgView", - "value": 10856 - }, - { - "key": "srgLike", - "value": 0 - }, - { - "key": "fbShare", - "value": 1 - }, - { - "key": "twitterShare", - "value": 0 - }, - { - "key": "googleShare", - "value": 0 - }, - { - "key": "whatsAppShare", - "value": 4 - } - ], - "displayable": true, - "position": 0, - "noEmbed": false, - "analyticsData": { - "ns_st_ep": "Tagesschau vom 09.05.2023: Mittagsausgabe", - "ns_st_ty": "Video", - "ns_st_ci": "f10ba470-6a3c-4479-8b2a-4529f7066234", - "ns_st_el": "712800", - "ns_st_cl": "712800", - "ns_st_sl": "712800", - "srg_mgeobl": "false", - "ns_st_tp": "1", - "ns_st_cn": "1", - "ns_st_ct": "vc12", - "ns_st_pn": "1" - }, - "analyticsMetadata": { - "media_segment": "Tagesschau vom 09.05.2023: Mittagsausgabe", - "media_type": "Video", - "media_segment_id": "f10ba470-6a3c-4479-8b2a-4529f7066234", - "media_episode_length": "713", - "media_segment_length": "713", - "media_number_of_segment_selected": "1", - "media_number_of_segments_total": "1", - "media_duration_category": "long", - "media_is_geoblocked": "false", - "media_urn": "urn:srf:video:f10ba470-6a3c-4479-8b2a-4529f7066234", - "media_assigned_tags": "srfnews" - }, - "eventData": "$6ff2a48d0d462b41$410ff54708fa967b1b5a4863b98b70e7c08f8b223eb4fbd85239404f0c7d12e0a8502795f1a5462f38175ce213be74ba77a88aeb3b50a06da75cb87cdaaae2ecb4f04ee334d9f58f52d9cf4ee06b3da9c27148a35023b64a6e15ddfeddc204095a92129c1f367abce039ec22cfcae78362dfb3c73cba811f9a848fe636c5f970d4e3bfed6e091a49b2ef83719cd19b6cba56641f4545d007aaa5b472d769cffbb13395244e9de20637f11669d1ffea314fd65ff529adcaade576174acf1e5c714753b38aa9cb9ed7644492362f1e012127cfe713d0ab37bf8a44a1972e32ed44e3cea634ceffaac2dd32e80fe0ee275e3554acfa4c22898d383da9e10cb44fb5ee8ecafeb085f8fa4a97b5596f8f550934e59d95bf39bc3a6442f32435052210fc68a4d4fc0fc3bf0496d5eccf05c4394458ef67312664cf9db40cda80996b8391e75620d1b905fa1436d6182f410302d4858560ac520ab7f96015aa23da7586f8eb1d7777315a86dcf2000ddc1ec5e2280716c91f7a4d8753fb6cfaf30519c20c319f945a59a76207ac17c45e67c1b424dec0abd7bd3a9d584d91c4147b7e39bdf9e7a303edc4b088f72e71043f283047d65a4d6d90e6c4b2d89b1db76d5ea7f28bf20108ff99d67e8b57884bf39d811adc0a22182fbfce9044bec9956d687903c332aef6baf7ae221d85a077a86870f524692593169e7d71abbc19f5647a2c", - "tagList": [ - "srfnews" - ], - "resourceList": [ - { - "url": "https://srf-vod-amd.akamaized.net/world/hls/ts20/2023/05/ts20_20230509_124500_18781731_v_webcast_h264_,q40,q10,q20,q30,q50,.mp4.csmil/master.m3u8?caption=srf/dcc5b238-7943-4fe7-8636-e41989d91408/episode/de/vod/vod.m3u8:de:Deutsch:sdh&webvttbaseurl=subtitles.eai-general.aws.srf.ch", - "quality": "SD", - "protocol": "HLS", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": false, - "live": false, - "mediaContainer": "MP4", - "audioCodec": "UNKNOWN", - "videoCodec": "H264", - "tokenType": "NONE", - "subtitleInformationList": [ - { - "locale": "de", - "language": "Deutsch", - "source": "HLS", - "type": "SDH" - } - ], - "analyticsData": { - "srg_mqual": "SD", - "srg_mpres": "DEFAULT" - }, - "analyticsMetadata": { - "media_streaming_quality": "SD", - "media_special_format": "DEFAULT", - "media_url": "https://srf-vod-amd.akamaized.net/world/hls/ts20/2023/05/ts20_20230509_124500_18781731_v_webcast_h264_,q40,q10,q20,q30,q50,.mp4.csmil/master.m3u8?caption=srf/dcc5b238-7943-4fe7-8636-e41989d91408/episode/de/vod/vod.m3u8:de:Deutsch:sdh&webvttbaseurl=subtitles.eai-general.aws.srf.ch" - } - }, - { - "url": "https://srf-vod-amd.akamaized.net/world/hls/ts20/2023/05/ts20_20230509_124500_18781731_v_webcast_h264_,q40,q10,q20,q30,q50,q60,.mp4.csmil/master.m3u8?caption=srf/dcc5b238-7943-4fe7-8636-e41989d91408/episode/de/vod/vod.m3u8:de:Deutsch:sdh&webvttbaseurl=subtitles.eai-general.aws.srf.ch", - "quality": "HD", - "protocol": "HLS", - "encoding": "H264", - "mimeType": "application/x-mpegURL", - "presentation": "DEFAULT", - "streaming": "HLS", - "dvr": false, - "live": false, - "mediaContainer": "MP4", - "audioCodec": "UNKNOWN", - "videoCodec": "H264", - "tokenType": "NONE", - "subtitleInformationList": [ - { - "locale": "de", - "language": "Deutsch", - "source": "HLS", - "type": "SDH" - } - ], - "analyticsData": { - "srg_mqual": "HD", - "srg_mpres": "DEFAULT" - }, - "analyticsMetadata": { - "media_streaming_quality": "HD", - "media_special_format": "DEFAULT", - "media_url": "https://srf-vod-amd.akamaized.net/world/hls/ts20/2023/05/ts20_20230509_124500_18781731_v_webcast_h264_,q40,q10,q20,q30,q50,q60,.mp4.csmil/master.m3u8?caption=srf/dcc5b238-7943-4fe7-8636-e41989d91408/episode/de/vod/vod.m3u8:de:Deutsch:sdh&webvttbaseurl=subtitles.eai-general.aws.srf.ch" - } - } - ], - "aspectRatio": "16:9", - "spriteSheet": { - "urn": "urn:srf:video:f10ba470-6a3c-4479-8b2a-4529f7066234", - "rows": 18, - "columns": 20, - "thumbnailHeight": 84, - "thumbnailWidth": 150, - "interval": 2000, - "url": "https://il.srgssr.ch/spritesheet/urn/srf/video/f10ba470-6a3c-4479-8b2a-4529f7066234/sprite-f10ba470-6a3c-4479-8b2a-4529f7066234.jpeg" - } - } - ], - "topicList": [ - { - "id": "a709c610-b275-4c0c-a496-cba304c36712", - "vendor": "SRF", - "transmission": "TV", - "urn": "urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712", - "title": "News", - "lead": "Hier finden Sie alle Sendungen von SRF zum Themenbereich News: aktuell, informativ und tiefgründig.", - "description": "Tagesschau, Meteo, 10vor10, Schweiz aktuell, Börse, Kinder News, Forward - die News-Sendungen von SRF stehen für seriös recherchierte Berichterstattung und kompetente Hintergrundberichterstattung." - } - ], - "analyticsData": { - "srg_pr_id": "dcc5b238-7943-4fe7-8636-e41989d91408", - "srg_plid": "ff969c14-c5a7-44ab-ab72-14d4c9e427a9", - "ns_st_pl": "Tagesschau", - "ns_st_pr": "Tagesschau vom 09.05.2023", - "ns_st_dt": "2023-05-09", - "ns_st_ddt": "2023-05-09", - "ns_st_tdt": "2023-05-09", - "ns_st_tm": "12:45:00", - "ns_st_tep": "2061009989527", - "ns_st_li": "0", - "ns_st_stc": "0866", - "ns_st_st": "SRF Online", - "ns_st_tpr": "ff969c14-c5a7-44ab-ab72-14d4c9e427a9", - "ns_st_en": "*null", - "ns_st_ge": "*null", - "ns_st_ia": "*null", - "ns_st_ce": "1", - "ns_st_cdm": "to", - "ns_st_cmt": "fc", - "srg_unit": "SRF", - "srg_c1": "News", - "srg_c2": "full", - "srg_wo": "0", - "srg_tv_id": "1981706492812", - "srg_fullLength": "full" - }, - "analyticsMetadata": { - "media_episode_id": "dcc5b238-7943-4fe7-8636-e41989d91408", - "media_show_id": "ff969c14-c5a7-44ab-ab72-14d4c9e427a9", - "media_show": "Tagesschau", - "media_episode": "Tagesschau vom 09.05.2023", - "media_is_livestream": "false", - "media_full_length": "full", - "media_enterprise_units": "SRF", - "media_joker1": "News", - "media_is_web_only": "false", - "media_production_source": "produced.for.broadcasting", - "media_tv_id": "2061009989527", - "media_thumbnail": "https://ws.srf.ch/asset/image/audio/c7f097f3-2738-4bc1-ae41-244399a3fbc9/EPISODE_IMAGE/1683630053.png/scale/width/344", - "media_publication_date": "2023-05-09", - "media_publication_time": "12:45:00", - "media_publication_datetime": "2023-05-09T12:45:00+02:00", - "media_tv_date": "2023-05-09", - "media_tv_time": "12:45:00", - "media_tv_datetime": "2023-05-09T12:45:00+02:00", - "media_content_group": "News", - "media_channel_id": "23FFBE1B-65CE-4188-ADD2-C724186C2C9F", - "media_channel_cs": "0866", - "media_channel_name": "SRF 1", - "media_since_publication_d": "2", - "media_since_publication_h": "69" - } - } -] diff --git a/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/CommandersActTrackerTest.kt b/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/CommandersActTrackerTest.kt deleted file mode 100644 index ef9d5a3b0..000000000 --- a/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/CommandersActTrackerTest.kt +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.test.filters.FlakyTest -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct -import ch.srgssr.pillarbox.analytics.commandersact.CommandersActPageView -import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType -import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent -import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository -import ch.srgssr.pillarbox.core.business.tracker.commandersact.CommandersActStreaming -import ch.srgssr.pillarbox.player.PillarboxPlayer -import ch.srgssr.pillarbox.player.test.utils.TestPlayer -import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import kotlin.math.abs -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class CommandersActTrackerTest { - private lateinit var commandersActDelegate: CommandersActDelegate - - @Before - fun setup() { - CommandersActStreaming.HEART_BEAT_DELAY = HEART_BEAT_DELAY - CommandersActStreaming.UPTIME_PERIOD = UPTIME_PERIOD - CommandersActStreaming.POS_PERIOD = POS_PERIOD - commandersActDelegate = CommandersActDelegate() - } - - private suspend fun createPlayerWithUrn(urn: String, playWhenReady: Boolean = true): TestPlayer { - val context = getInstrumentation().targetContext - val player = PillarboxPlayer( - context = context, - mediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = LocalMediaCompositionDataSource(context), - ), - mediaItemTrackerProvider = DefaultMediaItemTrackerRepository( - trackerRepository = MediaItemTrackerRepository(), - commandersAct = commandersActDelegate - ) - ) - player.volume = 0.0f - player.setMediaItem(MediaItem.Builder().setMediaId(urn).build()) - player.playWhenReady = playWhenReady - val testPlayer = TestPlayer(player) - testPlayer.prepare() - return testPlayer - } - - @Test - fun testStartEoF() = runTest { - val expected = listOf( - MediaEventType.Play.toString(), - MediaEventType.Eof.toString() - ) - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.VodShort) - player.waitForCondition { - it.playbackState == Player.STATE_ENDED || it.playbackState == Player.STATE_IDLE - } - player.release() - Assert.assertEquals(expected, commandersActDelegate.eventNames) - } - } - - @Test - fun testPlayStop() = runTest { - val expected = listOf( - MediaEventType.Play.toString(), - MediaEventType.Stop.toString() - ) - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod) - player.release() - Assert.assertEquals(expected, commandersActDelegate.eventNames) - } - } - - @Test - fun testPlaySeekPlay() = runTest { - val seekPositionMs = 2_000L - val expectedEvents = listOf( - CommandersActDelegate.Event(MediaEventType.Play.toString(), 0L), - CommandersActDelegate.Event(MediaEventType.Seek.toString(), 0L), - CommandersActDelegate.Event(MediaEventType.Play.toString(), seekPositionMs.milliseconds.inWholeSeconds), - CommandersActDelegate.Event(MediaEventType.Stop.toString()) - ) - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod) - player.seekTo(seekPositionMs) - player.release() - Assert.assertEquals(expectedEvents, commandersActDelegate.events) - } - } - - /** - * Test pause play seek play - * Seek event is not send but play event position should be the seek position. - */ - @Test - fun testPausePlaySeekPlay() = runTest { - val seekPositionMs = 2_000L - val expected = listOf( - CommandersActDelegate.Event(MediaEventType.Play.toString(), seekPositionMs.milliseconds.inWholeSeconds), - CommandersActDelegate.Event(MediaEventType.Stop.toString()) - ) - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod, false) - player.play() - player.seekTo(seekPositionMs) - player.release() - Assert.assertEquals(expected, commandersActDelegate.events) - } - } - - @Test - fun testPlayPauseSeekPause() = runTest { - val seekPositionMs = 4_000L - val expected = listOf( - MediaEventType.Play.toString(), - MediaEventType.Pause.toString(), - MediaEventType.Stop.toString() - ) - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod) - delay(2_000) - player.pause() - delay(2_000) - player.seekTo(seekPositionMs) - delay(2_000) - player.release() - Assert.assertEquals(expected, commandersActDelegate.eventNames) - } - } - - @FlakyTest(detail = "POS and UPTIME not always send due to timers") - @Test - fun testPosTime() = runTest { - val expected = listOf( - MediaEventType.Pos.toString(), - MediaEventType.Pos.toString(), - ) - commandersActDelegate.ignorePeriodicEvents = false - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod) - delay(POS_PERIOD + HEART_BEAT_DELAY + DELTA_PERIOD) - Assert.assertEquals(false, player.player.isCurrentMediaItemLive) - player.release() - val sent = commandersActDelegate.eventNames.filter { it == MediaEventType.Pos.toString() } - Assert.assertTrue(sent.size >= expected.size) - } - } - - @FlakyTest(detail = "POS and UPTIME not always send due to timers") - @Test - fun testUpTime() = runTest { - val expected = listOf( - MediaEventType.Uptime.toString(), - MediaEventType.Uptime.toString(), - ) - - commandersActDelegate.ignorePeriodicEvents = false - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Live) - delay(UPTIME_PERIOD + HEART_BEAT_DELAY + DELTA_PERIOD) - player.release() - val sent = commandersActDelegate.eventNames.filter { it == MediaEventType.Uptime.toString() } - Assert.assertTrue(sent.size >= expected.size) - } - } - - @FlakyTest(detail = "POS and UPTIME not always send due to timers") - @Test - fun testUpTimeLiveWithDvr() = runTest { - val expected = listOf( - MediaEventType.Uptime.toString(), - MediaEventType.Uptime.toString(), - ) - commandersActDelegate.ignorePeriodicEvents = false - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Dvr) - delay(UPTIME_PERIOD + HEART_BEAT_DELAY + DELTA_PERIOD) - player.release() - val sent = commandersActDelegate.eventNames.filter { it == MediaEventType.Uptime.toString() } - Assert.assertTrue(sent.size >= expected.size) - } - } - - @FlakyTest - @Test - fun testUpTimeLiveWithDvrTimeShift() = runTest { - val seekPosition = 80.seconds - commandersActDelegate.ignorePeriodicEvents = false - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Dvr) - val timeshift = (player.player.duration.milliseconds - seekPosition).inWholeSeconds - player.seekTo(seekPosition.inWholeMilliseconds) - delay(UPTIME_PERIOD + HEART_BEAT_DELAY + DELTA_PERIOD) - player.release() - val actualTimeshift = commandersActDelegate.events.first { - it.name == MediaEventType.Pos.toString() || it.name == MediaEventType.Uptime.toString() - }.timeshift - Assert.assertFalse(commandersActDelegate.events.isEmpty()) - Assert.assertTrue("Timeshift expected $timeshift but was $actualTimeshift", abs(timeshift - actualTimeshift) <= 15) - } - } - - @Test - fun testPauseSeekPause() = runTest { - val seekPositionMs = 4_000L - launch(Dispatchers.Main) { - val player = createPlayerWithUrn(LocalMediaCompositionDataSource.Vod, false) - player.seekTo(seekPositionMs) - player.release() - Assert.assertTrue(commandersActDelegate.eventNames.isEmpty()) - } - } - - internal class CommandersActDelegate( - var ignorePeriodicEvents: Boolean = true, - ) : - CommandersAct { - data class Event( - val name: String, - val position: Long = 0L, - val timeshift: Long = 0L - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Event - - if (name != other.name) return false - if (abs(position - other.position) > 1) return false - return true - } - - override fun hashCode(): Int { - var result = name.hashCode() - result = 31 * result + position.hashCode() - return result - } - } - - val eventNames = ArrayList() - val events = ArrayList() - - override fun sendTcMediaEvent(event: TCMediaEvent) { - if (event.isPeriodicEvent() && ignorePeriodicEvents) return - eventNames.add(event.name) - var position = 0L - var timeshift = 0L - if (!event.isEndEvent()) { - position = event.mediaPosition.inWholeSeconds - timeshift = event.timeShift?.inWholeSeconds ?: 0L - } - events.add(Event(name = event.name, position = position, timeshift = timeshift)) - } - - override fun putPermanentData(labels: Map) { - // Nothing - } - - override fun removePermanentData(label: String) { - // Nothing - } - - override fun getPermanentDataLabel(label: String): String? { - // Nothing - return null - } - - override fun sendPageView(pageView: CommandersActPageView) { - // Ignored - } - - override fun setConsentServices(consentServices: List) { - // Nothing - } - - override fun sendEvent(event: ch.srgssr.pillarbox.analytics.commandersact.CommandersActEvent) { - // Ignored - } - } - - companion object { - private val HEART_BEAT_DELAY = 3.seconds - private val UPTIME_PERIOD = 6.seconds - private val POS_PERIOD = 3.seconds - private val DELTA_PERIOD = 500.milliseconds - - private fun TCMediaEvent.isPeriodicEvent(): Boolean { - return eventType == MediaEventType.Pos || eventType == MediaEventType.Uptime - } - - private fun TCMediaEvent.isEndEvent(): Boolean { - return eventType == MediaEventType.Stop || eventType == MediaEventType.Eof - } - } -} diff --git a/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/LocalMediaCompositionDataSource.kt b/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/LocalMediaCompositionDataSource.kt deleted file mode 100644 index 7a295275b..000000000 --- a/pillarbox-core-business/src/androidTest/java/ch/srgssr/pillarbox/core/business/LocalMediaCompositionDataSource.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -import android.content.Context -import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient -import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionDataSource - -class LocalMediaCompositionDataSource(context: Context) : MediaCompositionDataSource { - private val localData = HashMap() - - init { - val json = context.assets.open("media-compositions.json").bufferedReader().use { it.readText() } - val listMediaComposition: List = DefaultHttpClient.jsonSerializer.decodeFromString(json) - for (mediaComposition in listMediaComposition) { - localData[mediaComposition.mainChapter.urn] = mediaComposition - } - } - - override suspend fun getMediaCompositionByUrn(urn: String): Result { - return localData[urn]?.let { - Result.success(it) - } ?: Result.failure(IllegalArgumentException("$urn not found!")) - } - - companion object { - const val Live = "urn:rts:video:8841634" - const val Dvr = "urn:rts:audio:3262363" - - /** - * Vod, ~ 11 min 52 seconds - */ - const val Vod = "urn:srf:video:f10ba470-6a3c-4479-8b2a-4529f7066234" - - /** - * Vod short, ~ 10 seconds - */ - const val VodShort = "urn:rts:video:13444428" - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepository.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepository.kt index 6ad366f02..8aed06e4a 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepository.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepository.kt @@ -14,27 +14,29 @@ import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker import ch.srgssr.pillarbox.player.tracker.MediaItemTracker import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerProvider import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext /** * Default media item tracker repository for SRG. * * @param trackerRepository The MediaItemTrackerRepository to use to store Tracker.Factory. * @param commandersAct CommanderAct instance to use for tracking. If set to null no tracking is made. + * @param coroutineContext The coroutine context in which to track the events. */ class DefaultMediaItemTrackerRepository internal constructor( private val trackerRepository: MediaItemTrackerRepository, - commandersAct: CommandersAct? -) : - MediaItemTrackerProvider by - trackerRepository { + commandersAct: CommandersAct?, + coroutineContext: CoroutineContext, +) : MediaItemTrackerProvider by trackerRepository { init { registerFactory(SRGEventLoggerTracker::class.java, SRGEventLoggerTracker.Factory()) registerFactory(ComScoreTracker::class.java, ComScoreTracker.Factory()) val commanderActOrEmpty = commandersAct ?: EmptyCommandersAct - registerFactory(CommandersActTracker::class.java, CommandersActTracker.Factory(commanderActOrEmpty)) + registerFactory(CommandersActTracker::class.java, CommandersActTracker.Factory(commanderActOrEmpty, coroutineContext)) } - constructor() : this(trackerRepository = MediaItemTrackerRepository(), SRGAnalytics.commandersAct) + constructor() : this(trackerRepository = MediaItemTrackerRepository(), SRGAnalytics.commandersAct, Dispatchers.Default) /** * Register factory diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt index 6b105c015..7cdb15d1a 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt @@ -16,11 +16,18 @@ import ch.srgssr.pillarbox.core.business.tracker.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.extension.audio import ch.srgssr.pillarbox.player.extension.isForced import ch.srgssr.pillarbox.player.utils.DebugLogger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.Timer -import kotlin.concurrent.fixedRateTimer -import kotlin.concurrent.scheduleAtFixedRate +import kotlin.coroutines.CoroutineContext import kotlin.math.abs import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO @@ -31,7 +38,8 @@ import kotlin.time.Duration.Companion.seconds internal class CommandersActStreaming( private val commandersAct: CommandersAct, private val player: ExoPlayer, - var currentData: CommandersActTracker.Data + var currentData: CommandersActTracker.Data, + private val coroutineContext: CoroutineContext, ) : AnalyticsListener { private enum class State { @@ -39,7 +47,7 @@ internal class CommandersActStreaming( } private var state: State = State.Idle - private var heartBeatTimer: Timer? = null + private var heartBeatJob: Job? = null private val playtimeTracker = TotalPlaytimeCounter() init { @@ -51,27 +59,51 @@ internal class CommandersActStreaming( private fun startHeartBeat() { stopHeartBeat() - heartBeatTimer = - fixedRateTimer( - name = "pillarbox-heart-beat", false, initialDelay = HEART_BEAT_DELAY.inWholeMilliseconds, - period = POS_PERIOD.inWholeMilliseconds - ) { - runBlocking(Dispatchers.Main) { - notifyPos(player.currentPosition.milliseconds) - } - }.also { - if (!player.isCurrentMediaItemLive) return@also - it.scheduleAtFixedRate(HEART_BEAT_DELAY.inWholeMilliseconds, period = UPTIME_PERIOD.inWholeMilliseconds) { - runBlocking(Dispatchers.Main) { - notifyUptime(player.currentPosition.milliseconds) + + heartBeatJob = CoroutineScope(coroutineContext).launch(CoroutineName("pillarbox-heart-beat")) { + val posUpdate = periodicTask( + period = POS_PERIOD, + task = ::notifyPos, + ) + val uptimeUpdate = periodicTask( + period = UPTIME_PERIOD, + continueLooping = { runOnMain(player::isCurrentMediaItemLive) }, + task = ::notifyUptime, + ) + + awaitAll(posUpdate, uptimeUpdate) + } + } + + private fun CoroutineScope.periodicTask( + period: Duration, + continueLooping: () -> Boolean = { true }, + task: (currentPosition: Duration) -> Unit + ): Deferred { + return async { + delay(HEART_BEAT_DELAY) + + while (isActive && continueLooping()) { + runOnMain { + if (player.playWhenReady) { + task(player.currentPosition.milliseconds) } } + + delay(period) } + } + } + + private fun runOnMain(callback: () -> T): T { + return runBlocking(Dispatchers.Main) { + callback() + } } private fun stopHeartBeat() { - heartBeatTimer?.cancel() - heartBeatTimer = null + heartBeatJob?.cancel() + heartBeatJob = null } override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt index ff056c2d1..61efa63d6 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt @@ -7,6 +7,7 @@ package ch.srgssr.pillarbox.core.business.tracker.commandersact import androidx.media3.exoplayer.ExoPlayer import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct import ch.srgssr.pillarbox.player.tracker.MediaItemTracker +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.milliseconds /** @@ -15,8 +16,12 @@ import kotlin.time.Duration.Companion.milliseconds * https://confluence.srg.beecollaboration.com/display/INTFORSCHUNG/standard+streaming+events%3A+sequence+of+events+for+media+player+actions * * @param commandersAct CommandersAct to send stream events + * @param coroutineContext The coroutine context in which to track the events */ -class CommandersActTracker(private val commandersAct: CommandersAct) : MediaItemTracker { +class CommandersActTracker( + private val commandersAct: CommandersAct, + private val coroutineContext: CoroutineContext, +) : MediaItemTracker { /** * Data for CommandersAct * @@ -33,7 +38,12 @@ class CommandersActTracker(private val commandersAct: CommandersAct) : MediaItem require(initialData is Data) commandersAct.enableRunningInBackground() currentData = initialData - analyticsStreaming = CommandersActStreaming(commandersAct = commandersAct, player = player, currentData = initialData) + analyticsStreaming = CommandersActStreaming( + commandersAct = commandersAct, + player = player, + currentData = initialData, + coroutineContext = coroutineContext, + ) analyticsStreaming?.let { player.addAnalyticsListener(it) } @@ -58,9 +68,12 @@ class CommandersActTracker(private val commandersAct: CommandersAct) : MediaItem /** * Factory */ - class Factory(private val commandersAct: CommandersAct) : MediaItemTracker.Factory { + class Factory( + private val commandersAct: CommandersAct, + private val coroutineContext: CoroutineContext, + ) : MediaItemTracker.Factory { override fun create(): MediaItemTracker { - return CommandersActTracker(commandersAct) + return CommandersActTracker(commandersAct, coroutineContext) } } } diff --git a/pillarbox-core-business/src/test/assets/media-composition.json b/pillarbox-core-business/src/test/assets/media-composition.json new file mode 100644 index 000000000..b711fd638 --- /dev/null +++ b/pillarbox-core-business/src/test/assets/media-composition.json @@ -0,0 +1,147 @@ +{ + "chapterUrn": "urn:rts:audio:3262363", + "episode": { + "id": "3262367", + "title": "Couleur 3 en direct", + "publishedDate": "2011-07-11T14:20:07+02:00", + "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", + "imageTitle": "Chaîne Couleur 3" + }, + "show": { + "id": "3262370", + "vendor": "RTS", + "transmission": "RADIO", + "urn": "urn:rts:show:radio:3262370", + "title": "Couleur 3 en direct", + "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", + "imageTitle": "Chaîne Couleur 3", + "bannerImageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/3x1", + "posterImageUrl": "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg", + "posterImageIsFallbackUrl": true, + "primaryChannelId": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", + "primaryChannelUrn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", + "audioDescriptionAvailable": false, + "subtitlesAvailable": false, + "multiAudioLanguagesAvailable": false, + "allowIndexing": false + }, + "channel": { + "id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", + "vendor": "RTS", + "urn": "urn:rts:channel:radio:8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", + "title": "Couleur 3", + "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", + "imageTitle": "Chaîne Couleur 3", + "transmission": "RADIO" + }, + "chapterList": [ + { + "id": "3262363", + "mediaType": "AUDIO", + "vendor": "RTS", + "urn": "urn:rts:audio:3262363", + "title": "Couleur 3 en direct", + "imageUrl": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", + "imageTitle": "Chaîne Couleur 3", + "type": "LIVESTREAM", + "date": "2011-07-11T14:20:07+02:00", + "duration": 0, + "playableAbroad": true, + "displayable": true, + "position": 0, + "noEmbed": false, + "analyticsMetadata": { + "media_segment": "Livestream", + "media_type": "Audio", + "media_segment_id": "3262363", + "media_episode_length": "0", + "media_segment_length": "0", + "media_number_of_segment_selected": "1", + "media_number_of_segments_total": "1", + "media_duration_category": "infinit.livestream", + "media_is_geoblocked": "false", + "media_is_web_only": "false", + "media_production_source": "produced.for.broadcasting", + "media_urn": "urn:rts:audio:3262363" + }, + "fullLengthMarkIn": 0, + "fullLengthMarkOut": 0, + "resourceList": [ + { + "url": "http://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8?", + "quality": "HD", + "protocol": "HLS-DVR", + "encoding": "H264", + "mimeType": "application/x-mpegURL", + "presentation": "DEFAULT", + "streaming": "HLS", + "dvr": true, + "live": true, + "mediaContainer": "MPEG2_TS", + "audioCodec": "AAC", + "videoCodec": "NONE", + "tokenType": "NONE", + "analyticsMetadata": { + "media_streaming_quality": "HD", + "media_special_format": "DEFAULT", + "media_url": "http://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8?" + }, + "streamOffset": 55000 + } + ] + } + ], + "analyticsData": { + "srg_pr_id": "3262367", + "srg_plid": "3262370", + "ns_st_pl": "Livestream", + "ns_st_pr": "Couleur 3 en direct", + "ns_st_dt": "2011-07-11", + "ns_st_ddt": "2011-07-11", + "ns_st_tdt": "2011-07-11", + "ns_st_tm": "14:20:07", + "ns_st_tep": "*null", + "ns_st_li": "1", + "ns_st_stc": "0867", + "ns_st_st": "Couleur 3", + "ns_st_tpr": "11562086", + "ns_st_en": "*null", + "ns_st_ge": "*null", + "ns_st_ia": "*null", + "ns_st_ce": "1", + "ns_st_cdm": "to", + "ns_st_cmt": "fc", + "srg_unit": "RTS", + "srg_c1": "live", + "srg_c2": "rts.ch_audio_couleur3", + "srg_c3": "COULEUR 3", + "srg_aod_prid": "3262367" + }, + "analyticsMetadata": { + "media_episode_id": "3262367", + "media_show_id": "11562086", + "media_show": "Oui Mais Non", + "media_episode": "Couleur 3 en direct", + "media_is_livestream": "true", + "media_full_length": "full", + "media_enterprise_units": "RTS", + "media_joker1": "live", + "media_joker2": "rts.ch_audio_couleur3", + "media_joker3": "COULEUR 3", + "media_is_web_only": "false", + "media_production_source": "produced.for.broadcasting", + "media_thumbnail": "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9/scale/width/344", + "media_publication_date": "2011-07-11", + "media_publication_time": "14:20:07", + "media_publication_datetime": "2011-07-11T14:20:07+02:00", + "media_tv_date": "2011-07-11", + "media_tv_time": "14:20:07", + "media_tv_datetime": "2011-07-11T14:20:07+02:00", + "media_content_group": "Couleur 3", + "media_channel_id": "8ceb28d9b3f1dd876d1df1780f908578cbefc3d7", + "media_channel_cs": "0867", + "media_channel_name": "Couleur 3", + "media_since_publication_d": "4322", + "media_since_publication_h": "103747" + } +} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepositoryTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepositoryTest.kt index 9d7574613..b539cd19e 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepositoryTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/DefaultMediaItemTrackerRepositoryTest.kt @@ -10,6 +10,7 @@ import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import io.mockk.mockk import io.mockk.verifySequence +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test class DefaultMediaItemTrackerRepositoryTest { @@ -21,6 +22,7 @@ class DefaultMediaItemTrackerRepositoryTest { DefaultMediaItemTrackerRepository( trackerRepository = trackerRepository, commandersAct = commandersAct, + coroutineContext = EmptyCoroutineContext, ) verifySequence { @@ -29,6 +31,7 @@ class DefaultMediaItemTrackerRepositoryTest { trackerRepository.registerFactory(CommandersActTracker::class.java, any(CommandersActTracker.Factory::class)) } } + @Test fun `DefaultMediaItemTrackerRepository registers some default factories without CommandersAct`() { val trackerRepository = mockk(relaxed = true) @@ -36,6 +39,7 @@ class DefaultMediaItemTrackerRepositoryTest { DefaultMediaItemTrackerRepository( trackerRepository = trackerRepository, commandersAct = null, + coroutineContext = EmptyCoroutineContext, ) verifySequence { diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt index 7da7d45b1..def8f428e 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt @@ -25,6 +25,7 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -43,6 +44,7 @@ class CommandersActStreamingTest { commandersAct = commandersAct, player = createExoPlayer(isPlaying = false), currentData = CommandersActTracker.Data(assets = emptyMap()), + coroutineContext = EmptyCoroutineContext, ) verify { @@ -81,6 +83,7 @@ class CommandersActStreamingTest { ), sourceId = "source_id", ), + coroutineContext = EmptyCoroutineContext, ) verify { @@ -148,6 +151,7 @@ class CommandersActStreamingTest { ), sourceId = "source_id", ), + coroutineContext = EmptyCoroutineContext, ) verify { diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index cb925266e..cc7331d2c 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -4,6 +4,8 @@ */ package ch.srgssr.pillarbox.core.business.tracker.commandersact +import android.content.Context +import android.os.Looper import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -12,13 +14,22 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct -import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Eof +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Pause +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Play +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Pos +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Seek +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Stop +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Uptime import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent import ch.srgssr.pillarbox.core.business.DefaultPillarbox import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource import ch.srgssr.pillarbox.core.business.MediaItemUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionDataSource import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker import ch.srgssr.pillarbox.player.data.MediaItemSource @@ -30,13 +41,25 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import io.mockk.verifyOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.math.abs +import kotlin.test.AfterTest import kotlin.test.BeforeTest -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -45,22 +68,29 @@ class CommandersActTrackerIntegrationTest { private lateinit var clock: FakeClock private lateinit var commandersAct: CommandersAct private lateinit var player: ExoPlayer + private lateinit var testDispatcher: TestDispatcher @BeforeTest + @OptIn(ExperimentalCoroutinesApi::class) fun setup() { clock = FakeClock(true) commandersAct = mockk(relaxed = true) + testDispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(testDispatcher) + + val context = ApplicationProvider.getApplicationContext() val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository( trackerRepository = MediaItemTrackerRepository(), commandersAct = commandersAct, + coroutineContext = testDispatcher, ) mediaItemTrackerRepository.registerFactory(ComScoreTracker::class.java) { mockk(relaxed = true) } val urnMediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = DefaultMediaCompositionDataSource() + mediaCompositionDataSource = LocalMediaCompositionWithFallbackDataSource(context) ) val mediaItemSource = object : MediaItemSource { override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { @@ -73,13 +103,23 @@ class CommandersActTrackerIntegrationTest { } player = DefaultPillarbox( - context = ApplicationProvider.getApplicationContext(), + context = context, mediaItemTrackerRepository = mediaItemTrackerRepository, mediaItemSource = mediaItemSource, clock = clock, ) } + @AfterTest + @OptIn(ExperimentalCoroutinesApi::class) + fun tearDown() { + player.release() + + shadowOf(Looper.getMainLooper()).idle() + + Dispatchers.resetMain() + } + @Test fun `player unprepared`() { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) @@ -115,15 +155,15 @@ class CommandersActTrackerIntegrationTest { assertEquals(3, tcMediaEvents.size) - assertEquals(MediaEventType.Play, tcMediaEvents[0].eventType) + assertEquals(Play, tcMediaEvents[0].eventType) assertTrue(tcMediaEvents[0].assets.isNotEmpty()) assertNull(tcMediaEvents[0].sourceId) - assertEquals(MediaEventType.Stop, tcMediaEvents[1].eventType) + assertEquals(Stop, tcMediaEvents[1].eventType) assertTrue(tcMediaEvents[1].assets.isNotEmpty()) assertNull(tcMediaEvents[1].sourceId) - assertEquals(MediaEventType.Play, tcMediaEvents[2].eventType) + assertEquals(Play, tcMediaEvents[2].eventType) assertTrue(tcMediaEvents[2].assets.isNotEmpty()) assertNull(tcMediaEvents[2].sourceId) } @@ -147,7 +187,7 @@ class CommandersActTrackerIntegrationTest { val tcMediaEvent = tcMediaEventSlot.captured - assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertEquals(Play, tcMediaEvent.eventType) assertTrue(tcMediaEvent.assets.isNotEmpty()) assertNull(tcMediaEvent.sourceId) } @@ -197,7 +237,7 @@ class CommandersActTrackerIntegrationTest { val tcMediaEvent = tcMediaEventSlot.captured - assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertEquals(Play, tcMediaEvent.eventType) assertTrue(tcMediaEvent.assets.isNotEmpty()) assertNull(tcMediaEvent.sourceId) } @@ -222,7 +262,7 @@ class CommandersActTrackerIntegrationTest { val tcMediaEvent = tcMediaEventSlot.captured - assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertEquals(Play, tcMediaEvent.eventType) assertTrue(tcMediaEvent.assets.isNotEmpty()) assertNull(tcMediaEvent.sourceId) } @@ -251,7 +291,7 @@ class CommandersActTrackerIntegrationTest { val tcMediaEvent = tcMediaEventSlot.captured - assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertEquals(Play, tcMediaEvent.eventType) assertTrue(tcMediaEvent.assets.isNotEmpty()) assertNull(tcMediaEvent.sourceId) } @@ -282,11 +322,11 @@ class CommandersActTrackerIntegrationTest { assertEquals(2, tcMediaEvents.size) - assertEquals(MediaEventType.Pause, tcMediaEvents[0].eventType) + assertEquals(Pause, tcMediaEvents[0].eventType) assertTrue(tcMediaEvents[0].assets.isNotEmpty()) assertNull(tcMediaEvents[0].sourceId) - assertEquals(MediaEventType.Play, tcMediaEvents[1].eventType) + assertEquals(Play, tcMediaEvents[1].eventType) assertTrue(tcMediaEvents[1].assets.isNotEmpty()) assertNull(tcMediaEvents[1].sourceId) } @@ -324,15 +364,15 @@ class CommandersActTrackerIntegrationTest { assertEquals(3, tcMediaEvents.size) - assertEquals(MediaEventType.Play, tcMediaEvents[0].eventType) + assertEquals(Play, tcMediaEvents[0].eventType) assertTrue(tcMediaEvents[0].assets.isNotEmpty()) assertNull(tcMediaEvents[0].sourceId) - assertEquals(MediaEventType.Pause, tcMediaEvents[1].eventType) + assertEquals(Pause, tcMediaEvents[1].eventType) assertTrue(tcMediaEvents[1].assets.isNotEmpty()) assertNull(tcMediaEvents[1].sourceId) - assertEquals(MediaEventType.Play, tcMediaEvents[2].eventType) + assertEquals(Play, tcMediaEvents[2].eventType) assertTrue(tcMediaEvents[2].assets.isNotEmpty()) assertNull(tcMediaEvents[2].sourceId) } @@ -362,11 +402,11 @@ class CommandersActTrackerIntegrationTest { assertEquals(2, tcMediaEvents.size) - assertEquals(MediaEventType.Stop, tcMediaEvents[0].eventType) + assertEquals(Stop, tcMediaEvents[0].eventType) assertTrue(tcMediaEvents[0].assets.isNotEmpty()) assertNull(tcMediaEvents[0].sourceId) - assertEquals(MediaEventType.Play, tcMediaEvents[1].eventType) + assertEquals(Play, tcMediaEvents[1].eventType) assertTrue(tcMediaEvents[1].assets.isNotEmpty()) assertNull(tcMediaEvents[1].sourceId) } @@ -397,19 +437,128 @@ class CommandersActTrackerIntegrationTest { assertEquals(3, tcMediaEvents.size) - assertEquals(MediaEventType.Play, tcMediaEvents[0].eventType) + assertEquals(Play, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(Seek, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + + assertEquals(Play, tcMediaEvents[2].eventType) + assertTrue(tcMediaEvents[2].assets.isNotEmpty()) + assertNull(tcMediaEvents[2].sourceId) + } + + @Test + fun `player pause, playing, seeking and playing`() { + val tcMediaEventSlot = slot() + + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + player.play() + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEventSlot)) + } + confirmVerified(commandersAct) + + val tcMediaEvent = tcMediaEventSlot.captured + + assertEquals(Play, tcMediaEvent.eventType) + assertTrue(tcMediaEvent.assets.isNotEmpty()) + assertNull(tcMediaEvent.sourceId) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `player playing, pause, seeking and pause`() = runTest(testDispatcher) { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(2.seconds.inWholeMilliseconds) + advanceTimeBy(2.seconds) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.pause() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPlayWhenReady(player, false) + + clock.advanceTime(2.seconds.inWholeMilliseconds) + advanceTimeBy(2.seconds) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(3, tcMediaEvents.size) + + assertEquals(Pause, tcMediaEvents[0].eventType) assertTrue(tcMediaEvents[0].assets.isNotEmpty()) assertNull(tcMediaEvents[0].sourceId) - assertEquals(MediaEventType.Seek, tcMediaEvents[1].eventType) + assertEquals(Pos, tcMediaEvents[1].eventType) assertTrue(tcMediaEvents[1].assets.isNotEmpty()) assertNull(tcMediaEvents[1].sourceId) - assertEquals(MediaEventType.Play, tcMediaEvents[2].eventType) + assertEquals(Play, tcMediaEvents[2].eventType) assertTrue(tcMediaEvents[2].assets.isNotEmpty()) assertNull(tcMediaEvents[2].sourceId) } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `player pause, seeking and pause`() = runTest(testDispatcher) { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.seconds.inWholeMilliseconds) + advanceTimeBy(2.seconds) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + } + confirmVerified(commandersAct) + } + @Test fun `player prepared and seek`() { player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) @@ -435,15 +584,15 @@ class CommandersActTrackerIntegrationTest { verify { commandersAct wasNot Called } } - @Ignore("Currently very flaky due to timer.") @Test - fun `check uptime and position updates`() { - val delay = 2.seconds + @OptIn(ExperimentalCoroutinesApi::class) + fun `check uptime and position updates for live`() = runTest(testDispatcher) { + val playTime = 10.seconds val tcMediaEvents = mutableListOf() - CommandersActStreaming.HEART_BEAT_DELAY = 0.5.seconds - CommandersActStreaming.POS_PERIOD = 0.5.seconds - CommandersActStreaming.UPTIME_PERIOD = 1.seconds + CommandersActStreaming.HEART_BEAT_DELAY = 1.seconds + CommandersActStreaming.POS_PERIOD = 2.seconds + CommandersActStreaming.UPTIME_PERIOD = 4.seconds player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) player.prepare() @@ -452,13 +601,24 @@ class CommandersActTrackerIntegrationTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) - clock.advanceTime(delay.inWholeMilliseconds) - Thread.sleep(delay.inWholeMilliseconds) + clock.advanceTime(playTime.inWholeMilliseconds) + advanceTimeBy(playTime) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + player.playWhenReady = false TestPlayerRunHelper.runUntilPlayWhenReady(player, false) TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + // Advance a bit more in time to ensure that no events are sent after pause + clock.advanceTime(playTime.inWholeMilliseconds) + advanceTimeBy(playTime) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + assertTrue(player.isCurrentMediaItemLive) + verifyOrder { commandersAct.enableRunningInBackground() commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) @@ -469,42 +629,177 @@ class CommandersActTrackerIntegrationTest { commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) } confirmVerified(commandersAct) - assertEquals(8, tcMediaEvents.size) + assertEquals(10, tcMediaEvents.size) - assertEquals(MediaEventType.Pause, tcMediaEvents[0].eventType) - assertTrue(tcMediaEvents[0].assets.isNotEmpty()) - assertNull(tcMediaEvents[0].sourceId) + assertEquals(listOf(Pause, Pos, Uptime, Pos, Pos, Uptime, Pos, Uptime, Pos, Play), tcMediaEvents.map { it.eventType }) + assertTrue(tcMediaEvents.all { it.assets.isNotEmpty() }) + assertTrue(tcMediaEvents.all { it.sourceId == null }) + } - assertEquals(MediaEventType.Pos, tcMediaEvents[1].eventType) - assertTrue(tcMediaEvents[1].assets.isNotEmpty()) - assertNull(tcMediaEvents[1].sourceId) + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `check uptime and position updates for dvr with time shift`() = runTest(testDispatcher) { + val playTime = 5.seconds + val seekPosition = 80.seconds + val tcMediaEvents = mutableListOf() - assertEquals(MediaEventType.Uptime, tcMediaEvents[2].eventType) - assertTrue(tcMediaEvents[2].assets.isNotEmpty()) - assertNull(tcMediaEvents[2].sourceId) + CommandersActStreaming.HEART_BEAT_DELAY = 1.seconds + CommandersActStreaming.POS_PERIOD = 2.seconds + CommandersActStreaming.UPTIME_PERIOD = 4.seconds + + player.setMediaItem(MediaItemUrn(URN_DVR)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.seekTo(seekPosition.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + clock.advanceTime(playTime.inWholeMilliseconds) + advanceTimeBy(playTime) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.stop() - assertEquals(MediaEventType.Pos, tcMediaEvents[3].eventType) - assertTrue(tcMediaEvents[3].assets.isNotEmpty()) - assertNull(tcMediaEvents[3].sourceId) + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(7, tcMediaEvents.size) + + assertEquals(listOf(Stop, Pos, Uptime, Pos, Play, Seek, Play), tcMediaEvents.map { it.eventType }) + assertTrue(tcMediaEvents.all { it.assets.isNotEmpty() }) + assertTrue(tcMediaEvents.all { it.sourceId == null }) - assertEquals(MediaEventType.Pos, tcMediaEvents[4].eventType) - assertTrue(tcMediaEvents[4].assets.isNotEmpty()) - assertNull(tcMediaEvents[4].sourceId) + val timeShift = (player.duration.milliseconds - seekPosition).inWholeSeconds + val actualTimeShift = tcMediaEvents.first { + it.eventType == Pos || it.eventType == Uptime + }.timeShift?.inWholeSeconds ?: 0L - assertEquals(MediaEventType.Uptime, tcMediaEvents[5].eventType) - assertTrue(tcMediaEvents[5].assets.isNotEmpty()) - assertNull(tcMediaEvents[5].sourceId) + assertTrue(abs(timeShift - actualTimeShift) <= 15L, "Expected time shift to be <$timeShift>, but was <$actualTimeShift>") + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `check uptime and position updates for not live`() = runTest(testDispatcher) { + val playTime = 10.seconds + val tcMediaEvents = mutableListOf() - assertEquals(MediaEventType.Pos, tcMediaEvents[6].eventType) - assertTrue(tcMediaEvents[6].assets.isNotEmpty()) - assertNull(tcMediaEvents[6].sourceId) + CommandersActStreaming.HEART_BEAT_DELAY = 1.seconds + CommandersActStreaming.POS_PERIOD = 2.seconds + CommandersActStreaming.UPTIME_PERIOD = 4.seconds - assertEquals(MediaEventType.Play, tcMediaEvents[7].eventType) - assertTrue(tcMediaEvents[7].assets.isNotEmpty()) - assertNull(tcMediaEvents[7].sourceId) + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(playTime.inWholeMilliseconds) + advanceTimeBy(playTime) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlayWhenReady(player, false) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + // Advance a bit more in time to ensure that no events are sent after pause + clock.advanceTime(playTime.inWholeMilliseconds) + advanceTimeBy(playTime) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + assertFalse(player.isCurrentMediaItemLive) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(7, tcMediaEvents.size) + + assertEquals(listOf(Pause, Pos, Pos, Pos, Pos, Pos, Play), tcMediaEvents.map { it.eventType }) + assertTrue(tcMediaEvents.all { it.assets.isNotEmpty() }) + assertTrue(tcMediaEvents.all { it.sourceId == null }) + } + + @Test + fun `start EoF`() = runTest(testDispatcher) { + val tcMediaEvents = mutableListOf() + + CommandersActStreaming.HEART_BEAT_DELAY = 1.seconds + CommandersActStreaming.POS_PERIOD = 2.seconds + CommandersActStreaming.UPTIME_PERIOD = 4.seconds + + player.setMediaItem(MediaItemUrn(URN_VOD_SHORT)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(2, tcMediaEvents.size) + + assertEquals(listOf(Eof, Play), tcMediaEvents.map { it.eventType }) + assertTrue(tcMediaEvents.all { it.assets.isNotEmpty() }) + assertTrue(tcMediaEvents.all { it.sourceId == null }) + } + + private class LocalMediaCompositionWithFallbackDataSource( + context: Context, + private val fallbackDataSource: MediaCompositionDataSource = DefaultMediaCompositionDataSource(), + ) : MediaCompositionDataSource { + private var mediaComposition: MediaComposition? = null + + init { + val json = context.assets.open("media-composition.json").bufferedReader().use { it.readText() } + + mediaComposition = DefaultHttpClient.jsonSerializer.decodeFromString(json) + } + + override suspend fun getMediaCompositionByUrn(urn: String): Result { + return if (urn == URN_DVR) { + runCatching { + requireNotNull(mediaComposition) + } + } else { + fallbackDataSource.getMediaCompositionByUrn(urn) + } + } } private companion object { @@ -512,5 +807,7 @@ class CommandersActTrackerIntegrationTest { private const val URN_AUDIO = "urn:rts:audio:13598743" private const val URN_LIVE_VIDEO = "urn:rts:video:8841634" private const val URN_NOT_LIVE_VIDEO = "urn:rsi:video:15916771" + private const val URN_VOD_SHORT = "urn:rts:video:13444428" + private const val URN_DVR = "urn:rts:audio:3262363" } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt index e526b196d..af2e2bfe1 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt @@ -16,6 +16,7 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import org.junit.runner.RunWith +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -29,7 +30,7 @@ class CommandersActTrackerTest { fun `start() requires a non-null initial data`() { val player = mockk(relaxed = true) val commandersActs = mockk(relaxed = true) - val commandersActTracker = CommandersActTracker(commandersActs) + val commandersActTracker = CommandersActTracker(commandersActs, EmptyCoroutineContext) commandersActTracker.start( player = player, @@ -41,7 +42,7 @@ class CommandersActTrackerTest { fun `start() requires an instance of CommandersActTracker#Data instance for the initial data`() { val player = mockk(relaxed = true) val commandersActs = mockk(relaxed = true) - val commandersActTracker = CommandersActTracker(commandersActs) + val commandersActTracker = CommandersActTracker(commandersActs, EmptyCoroutineContext) commandersActTracker.start( player = player, @@ -52,7 +53,7 @@ class CommandersActTrackerTest { @Test(expected = IllegalArgumentException::class) fun `update() requires an instance of CommandersActTracker#Data instance for the data`() { val commandersActs = mockk(relaxed = true) - val commandersActTracker = CommandersActTracker(commandersActs) + val commandersActTracker = CommandersActTracker(commandersActs, EmptyCoroutineContext) commandersActTracker.update(data = "My data") } @@ -63,7 +64,7 @@ class CommandersActTrackerTest { every { isPlaying } returns true } val commandersAct = mockk(relaxed = true) - val commandersActTracker = CommandersActTracker(commandersAct) + val commandersActTracker = CommandersActTracker(commandersAct, EmptyCoroutineContext) val commandersActStreamingSlot = slot() val tcMediaEventSlots = mutableListOf() diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt index 20489d537..3772acacf 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt @@ -32,6 +32,7 @@ import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder import org.junit.runner.RunWith +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.BeforeTest import kotlin.test.Ignore import kotlin.test.Test @@ -53,6 +54,7 @@ class ComScoreTrackerIntegrationTest { val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository( trackerRepository = MediaItemTrackerRepository(), commandersAct = null, + coroutineContext = EmptyCoroutineContext, ) mediaItemTrackerRepository.registerFactory(ComScoreTracker::class.java) { ComScoreTracker(streamingAnalytics) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt index a68686953..32bc1eb5f 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,7 +91,7 @@ private fun ListStreamView( ) if (index < playlist.items.lastIndex) { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsHome.kt index fd1cefa63..dc581f94f 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsHome.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -149,7 +149,7 @@ private fun ListsHome(onContentSelected: (ContentList) -> Unit) { ) if (index < section.contentList.lastIndex) { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsSubSection.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsSubSection.kt index e4a9cd84f..ab8bb2d91 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsSubSection.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/lists/ListsSubSection.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -114,7 +114,7 @@ fun ListsSubSection( ) if (index < items.itemCount - 1) { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/playlist/MediaItemLibrary.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/playlist/MediaItemLibrary.kt index 60f2d48bd..44e5e6c4f 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/playlist/MediaItemLibrary.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/playlist/MediaItemLibrary.kt @@ -15,7 +15,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface @@ -94,7 +94,7 @@ private fun DialogContent( text = "Add to the playlist", style = MaterialTheme.typography.headlineMedium ) - Divider() + HorizontalDivider() LazyColumn( modifier = Modifier .weight(0.5f) @@ -113,7 +113,7 @@ private fun DialogContent( ) } } - Divider() + HorizontalDivider() Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSpeedSettings.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSpeedSettings.kt index 24e76154c..7da112aac 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSpeedSettings.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSpeedSettings.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -44,7 +44,7 @@ fun PlaybackSpeedSettings( ) if (index < playbackSpeeds.lastIndex) { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/TrackSelectionSettings.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/TrackSelectionSettings.kt index d98a1d70a..eaabc7414 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/TrackSelectionSettings.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/TrackSelectionSettings.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.HearingDisabled -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -63,7 +63,7 @@ fun TrackSelectionSettings( ) } ) - Divider() + HorizontalDivider() } item { SettingsOption( @@ -74,7 +74,7 @@ fun TrackSelectionSettings( Text(text = stringResource(R.string.disabled)) } ) - Divider() + HorizontalDivider() } tracksSetting.tracks.forEach { group -> items(group.length) { trackIndex -> @@ -117,7 +117,7 @@ fun TrackSelectionSettings( ) } item { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/search/SearchHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/search/SearchHome.kt index eeab8f564..b1a5951d3 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/search/SearchHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/search/SearchHome.kt @@ -31,10 +31,10 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -165,7 +165,7 @@ private fun SearchResultList( ) if (index < items.itemCount - 1) { - Divider() + HorizontalDivider() } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt index 4a9141d27..5fb60177a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -70,7 +70,7 @@ fun ShowcasesHome(navController: NavController) { onClick = { navController.navigate(NavigationRoutes.simplePlayer) } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.story), @@ -93,7 +93,7 @@ fun ShowcasesHome(navController: NavController) { ) if (index < playlists.lastIndex) { - Divider() + HorizontalDivider() } } } @@ -112,7 +112,7 @@ fun ShowcasesHome(navController: NavController) { ) } - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.auto), @@ -140,7 +140,7 @@ fun ShowcasesHome(navController: NavController) { } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.adaptive), @@ -148,14 +148,14 @@ fun ShowcasesHome(navController: NavController) { onClick = { navController.navigate(NavigationRoutes.adaptive) } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.player_swap), modifier = itemModifier, onClick = { navController.navigate(NavigationRoutes.playerSwap) } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.tracker_example), @@ -163,7 +163,7 @@ fun ShowcasesHome(navController: NavController) { onClick = { navController.navigate(NavigationRoutes.trackingSample) } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.update_media_item_example), @@ -175,7 +175,7 @@ fun ShowcasesHome(navController: NavController) { } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.smooth_seeking_example), @@ -187,7 +187,7 @@ fun ShowcasesHome(navController: NavController) { } ) - Divider() + HorizontalDivider() DemoListItemView( title = stringResource(R.string.video_360), diff --git a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/PlayerListenerCommander.kt b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/PlayerListenerCommander.kt index ab250d310..d4b244b8a 100644 --- a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/PlayerListenerCommander.kt +++ b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/PlayerListenerCommander.kt @@ -27,12 +27,6 @@ import androidx.media3.common.text.CueGroup open class PlayerListenerCommander(player: Player) : ForwardingPlayer(player), Listener { private val listeners = mutableListOf() - /** - * Has player listener - */ - val hasPlayerListener: Boolean - get() = listeners.isNotEmpty() - @SuppressLint("MissingSuperCall") override fun addListener(listener: Listener) { listeners.add(listener) diff --git a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPlayer.kt b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPlayer.kt deleted file mode 100644 index 7ce5d93c5..000000000 --- a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPlayer.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.test.utils - -import androidx.media3.common.Player -import kotlinx.coroutines.delay - -class TestPlayer(val player: Player) { - - suspend fun prepare() { - player.prepare() - player.waitForPlaybackState(Player.STATE_READY) - } - - suspend fun play() { - player.play() - player.waitIsPlaying() - } - - suspend fun pause() { - player.pause() - player.waitForPause() - } - - suspend fun seekTo(positionMs: Long) { - player.seekTo(positionMs) - player.waitForPlaybackState(Player.STATE_READY) - } - - suspend fun release() { - player.stop() - player.release() - player.waitForPlaybackState(Player.STATE_IDLE) - } - - suspend fun stop() { - player.stop() - player.waitForPlaybackState(Player.STATE_IDLE) - } - - suspend fun waitForCondition(condition: (Player) -> Boolean) { - player.waitForCondition(condition) - } - - companion object { - private const val WAIT_DELAY = 200L - - @Suppress("TooGenericExceptionThrown") - suspend fun Player.waitForCondition(condition: (Player) -> Boolean) { - while (!condition(this)) { - if (playerError != null) throw RuntimeException(playerError) - delay(WAIT_DELAY) - } - } - - suspend fun Player.waitForPlaybackState(state: @Player.State Int) { - waitForCondition { - it.playbackState == state - } - } - - suspend fun Player.waitForPause() { - waitForCondition { - it.playbackState == Player.STATE_READY && !it.playWhenReady - } - } - - suspend fun Player.waitIsPlaying() { - waitForCondition { - it.isPlaying - } - } - } -}