From 534d462553c56a9d23eb4392a2f1310c156fc9e0 Mon Sep 17 00:00:00 2001 From: Abderrahim-Mouhcine Date: Sun, 16 Aug 2026 19:57:53 +0100 Subject: [PATCH] Initial upload: iptv-android-tv-hermes --- .gitignore | 19 + ARCHITECTURE.md | 136 ++ BUILD_INSTALL_MIBOX_USAGE_SORT.md | 30 + COMMANDES_BUILD_INSTALL_MIBOX.md | 103 ++ FAST_SCROLL_V10_1_COMPILE_FIX_NOTES.md | 39 + FAST_SCROLL_V10_NOTES.md | 37 + FIX_AUTH_RESPONSE_V22.md | 30 + FIX_CONTINUE_SEARCH_V4.md | 43 + FIX_CONTINUE_WATCHING_V12.md | 26 + FIX_FAST_CONTINUE_SEARCH_V11.md | 49 + FIX_HOME_SCROLL_COMPILE_V16.md | 14 + FIX_HOME_SCROLL_SIMPLE_V17.md | 12 + FIX_LIVE_FAST_CACHE_V8.md | 45 + FIX_LIVE_LOAD_V7.md | 26 + FIX_LOGIN_DIALOG_INPUT_V19.md | 23 + FIX_LOGIN_FOCUS_V20.md | 20 + FIX_LOGIN_INPUT_V18.md | 17 + FIX_LOGIN_SERVER_V21.md | 22 + FIX_LOGIN_SUCCESS_NAV_V23.md | 24 + FIX_PERFORMANCE_ROLLBACK_V9.md | 30 + FIX_RECENT_LIVE_V13.md | 26 + FIX_ROLLBACK_V5_STABLE_V10.md | 22 + FIX_USAGE_SORT_AND_APP_NAME.md | 44 + FIX_USAGE_SORT_V2.md | 22 + FIX_USAGE_SORT_V3.md | 32 + FIX_YOUTUBE_CATEGORY_V5.md | 35 + FOCUS_FIX_NOTES.md | 28 + FOCUS_FIX_V2_NOTES.md | 28 + FOCUS_FIX_V3_NOTES.md | 69 + FOCUS_FIX_V4_NOTES.md | 27 + FOCUS_FIX_V5_NOTES.md | 24 + FOCUS_FIX_V6_NOTES.md | 28 + FOCUS_FIX_V7_NOTES.md | 20 + FOCUS_FIX_V8_NOTES.md | 16 + MENU_HOME_V9_NOTES.md | 81 + README_INSTALLATION_YOUTUBE_MINIPLAYER.md | 66 + SPECIFICATIONS.md | 486 ++++++ Site web/README.md | 39 + Site web/assets/logo.svg | 1 + Site web/index.html | 189 +++ Site web/script.js | 1 + Site web/styles.css | 1 + TESTING.md | 130 ++ TV_OK.m3u | 1471 +++++++++++++++++ app/.gitignore | 19 + app/ARCHITECTURE.md | 136 ++ app/COMMANDES_BUILD_INSTALL_MIBOX.md | 103 ++ app/FOCUS_FIX_NOTES.md | 28 + app/FOCUS_FIX_V2_NOTES.md | 28 + app/FOCUS_FIX_V3_NOTES.md | 69 + app/SPECIFICATIONS.md | 486 ++++++ app/TESTING.md | 130 ++ app/app/build.gradle.kts | 123 ++ app/app/proguard-rules.pro | 37 + app/app/src/main/AndroidManifest.xml | 36 + .../java/ma/tkgraph/xtreamtv/AppContainer.kt | 53 + .../java/ma/tkgraph/xtreamtv/MainActivity.kt | 112 ++ .../ma/tkgraph/xtreamtv/XtreamApplication.kt | 36 + .../xtreamtv/data/model/AuthResponse.kt | 23 + .../xtreamtv/data/model/EpgProgramDto.kt | 55 + .../xtreamtv/data/model/EpgShortResponse.kt | 11 + .../xtreamtv/data/model/LiveCategoryDto.kt | 12 + .../xtreamtv/data/model/LiveStreamDto.kt | 24 + .../tkgraph/xtreamtv/data/model/SeriesDto.kt | 57 + .../ma/tkgraph/xtreamtv/data/model/UiState.kt | 16 + .../ma/tkgraph/xtreamtv/data/model/VodDto.kt | 29 + .../xtreamtv/data/model/XtreamConfig.kt | 23 + .../xtreamtv/data/model/XtreamException.kt | 25 + .../xtreamtv/data/network/XtreamApi.kt | 98 ++ .../data/network/XtreamBaseUrlInterceptor.kt | 56 + .../data/network/XtreamErrorInterceptor.kt | 51 + .../data/repository/AuthRepository.kt | 89 + .../xtreamtv/data/repository/EpgRepository.kt | 41 + .../xtreamtv/data/repository/ErrorHelpers.kt | 26 + .../data/repository/FavoritesRepository.kt | 65 + .../data/repository/LiveRepository.kt | 60 + .../xtreamtv/data/repository/M3uParser.kt | 145 ++ .../xtreamtv/data/repository/M3uRepository.kt | 54 + .../data/repository/SeriesRepository.kt | 49 + .../data/repository/UsageRepository.kt | 40 + .../xtreamtv/data/repository/VodRepository.kt | 33 + .../xtreamtv/data/storage/SecureStorage.kt | 134 ++ .../xtreamtv/ui/components/FocusHighlight.kt | 57 + .../ui/components/YoutubeMiniPlayer.kt | 658 ++++++++ .../xtreamtv/ui/navigation/NavState.kt | 40 + .../tkgraph/xtreamtv/ui/screens/HomeScreen.kt | 1073 ++++++++++++ .../xtreamtv/ui/screens/LoginScreen.kt | 397 +++++ .../xtreamtv/ui/screens/PlayerScreen.kt | 285 ++++ .../xtreamtv/ui/screens/ProgramGuideScreen.kt | 112 ++ .../xtreamtv/ui/screens/ReplayGuideScreen.kt | 96 ++ .../xtreamtv/ui/screens/SeriesScreen.kt | 210 +++ .../tkgraph/xtreamtv/ui/screens/VodScreen.kt | 205 +++ .../tkgraph/xtreamtv/ui/theme/XtreamTheme.kt | 54 + .../xtreamtv/ui/viewmodels/GuideViewModel.kt | 44 + .../xtreamtv/ui/viewmodels/HomeViewModel.kt | 97 ++ .../xtreamtv/ui/viewmodels/LoginViewModel.kt | 107 ++ .../xtreamtv/ui/viewmodels/PlayerViewModel.kt | 153 ++ .../xtreamtv/ui/viewmodels/ReplayViewModel.kt | 57 + .../xtreamtv/ui/viewmodels/SeriesViewModel.kt | 74 + .../xtreamtv/ui/viewmodels/VodViewModel.kt | 63 + .../xtreamtv/util/CategorySortUtils.kt | 88 + .../tkgraph/xtreamtv/util/SessionManager.kt | 14 + .../ma/tkgraph/xtreamtv/util/UrlBuilder.kt | 53 + .../tkgraph/xtreamtv/util/UsageSortUtils.kt | 21 + app/app/src/main/res/drawable/ic_banner.png | Bin 0 -> 12707 bytes .../main/res/drawable/launch_background.xml | 10 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5154 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2744 bytes .../src/main/res/mipmap-xhdpi/ic_banner.png | Bin 0 -> 12707 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8093 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 15659 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 25193 bytes app/app/src/main/res/values-ar/strings.xml | 78 + app/app/src/main/res/values-fr/strings.xml | 78 + app/app/src/main/res/values/colors.xml | 19 + app/app/src/main/res/values/config.xml | 9 + app/app/src/main/res/values/strings.xml | 78 + app/app/src/main/res/values/themes.xml | 12 + .../main/res/xml/network_security_config.xml | 9 + .../data/repository/AuthRepositoryTest.kt | 74 + .../xtreamtv/util/CategorySortUtilsTest.kt | 50 + .../tkgraph/xtreamtv/util/UrlBuilderTest.kt | 50 + app/build.gradle.kts | 123 ++ app/docs/ERROR-HANDLING.md | 153 ++ app/docs/SECURITY.md | 159 ++ app/gradle.properties | 6 + app/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes app/gradle/wrapper/gradle-wrapper.properties | 7 + app/gradlew | 248 +++ app/gradlew.bat | 93 ++ app/proguard-rules.pro | 37 + app/settings.gradle.kts | 18 + app/src/main/AndroidManifest.xml | 36 + .../java/ma/tkgraph/xtreamtv/AppContainer.kt | 57 + .../java/ma/tkgraph/xtreamtv/MainActivity.kt | 145 ++ .../ma/tkgraph/xtreamtv/XtreamApplication.kt | 36 + .../xtreamtv/data/model/AuthResponse.kt | 23 + .../xtreamtv/data/model/EpgProgramDto.kt | 55 + .../xtreamtv/data/model/EpgShortResponse.kt | 11 + .../xtreamtv/data/model/LiveCategoryDto.kt | 12 + .../xtreamtv/data/model/LiveStreamDto.kt | 24 + .../tkgraph/xtreamtv/data/model/SeriesDto.kt | 57 + .../ma/tkgraph/xtreamtv/data/model/UiState.kt | 16 + .../ma/tkgraph/xtreamtv/data/model/VodDto.kt | 29 + .../xtreamtv/data/model/XtreamConfig.kt | 23 + .../xtreamtv/data/model/XtreamException.kt | 25 + .../xtreamtv/data/network/XtreamApi.kt | 98 ++ .../data/network/XtreamBaseUrlInterceptor.kt | 80 + .../data/network/XtreamErrorInterceptor.kt | 51 + .../data/repository/AuthRepository.kt | 156 ++ .../xtreamtv/data/repository/EpgRepository.kt | 41 + .../xtreamtv/data/repository/ErrorHelpers.kt | 26 + .../data/repository/FavoritesRepository.kt | 65 + .../data/repository/LiveRepository.kt | 73 + .../xtreamtv/data/repository/M3uParser.kt | 151 ++ .../xtreamtv/data/repository/M3uRepository.kt | 68 + .../repository/PlaybackProgressRepository.kt | 258 +++ .../data/repository/RecentLiveRepository.kt | 102 ++ .../data/repository/SeriesRepository.kt | 52 + .../data/repository/UsageRepository.kt | 40 + .../xtreamtv/data/repository/VodRepository.kt | 36 + .../data/repository/YoutubeRepository.kt | 107 ++ .../xtreamtv/data/storage/SecureStorage.kt | 175 ++ .../xtreamtv/ui/components/FastScrollKeys.kt | 103 ++ .../xtreamtv/ui/components/FocusHighlight.kt | 57 + .../ui/components/YoutubeMiniPlayer.kt | 658 ++++++++ .../xtreamtv/ui/navigation/NavState.kt | 51 + .../xtreamtv/ui/screens/GlobalSearchScreen.kt | 343 ++++ .../tkgraph/xtreamtv/ui/screens/HomeScreen.kt | 1145 +++++++++++++ .../xtreamtv/ui/screens/LoginScreen.kt | 599 +++++++ .../xtreamtv/ui/screens/MainMenuScreen.kt | 744 +++++++++ .../xtreamtv/ui/screens/PlayerScreen.kt | 553 +++++++ .../xtreamtv/ui/screens/ProgramGuideScreen.kt | 112 ++ .../xtreamtv/ui/screens/ReplayGuideScreen.kt | 96 ++ .../xtreamtv/ui/screens/SeriesScreen.kt | 483 ++++++ .../tkgraph/xtreamtv/ui/screens/VodScreen.kt | 336 ++++ .../xtreamtv/ui/screens/YoutubeScreen.kt | 545 ++++++ .../tkgraph/xtreamtv/ui/theme/XtreamTheme.kt | 54 + .../ui/viewmodels/GlobalSearchViewModel.kt | 296 ++++ .../xtreamtv/ui/viewmodels/GuideViewModel.kt | 44 + .../xtreamtv/ui/viewmodels/HomeViewModel.kt | 134 ++ .../xtreamtv/ui/viewmodels/LoginViewModel.kt | 107 ++ .../ui/viewmodels/MainMenuViewModel.kt | 301 ++++ .../xtreamtv/ui/viewmodels/PlayerViewModel.kt | 184 +++ .../xtreamtv/ui/viewmodels/ReplayViewModel.kt | 57 + .../xtreamtv/ui/viewmodels/SeriesViewModel.kt | 104 ++ .../xtreamtv/ui/viewmodels/VodViewModel.kt | 93 ++ .../ui/viewmodels/YoutubeViewModel.kt | 53 + .../xtreamtv/util/CategorySortUtils.kt | 101 ++ .../tkgraph/xtreamtv/util/SessionManager.kt | 14 + .../ma/tkgraph/xtreamtv/util/UrlBuilder.kt | 53 + .../tkgraph/xtreamtv/util/UsageSortUtils.kt | 21 + .../tkgraph/xtreamtv/util/YoutubeUrlUtils.kt | 165 ++ app/src/main/res/drawable/ic_banner.png | Bin 0 -> 12707 bytes .../main/res/drawable/launch_background.xml | 10 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5154 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2744 bytes app/src/main/res/mipmap-xhdpi/ic_banner.png | Bin 0 -> 12707 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8093 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 15659 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 25193 bytes app/src/main/res/values-ar/strings.xml | 78 + app/src/main/res/values-fr/strings.xml | 78 + app/src/main/res/values/colors.xml | 19 + app/src/main/res/values/config.xml | 9 + app/src/main/res/values/strings.xml | 78 + app/src/main/res/values/themes.xml | 12 + .../main/res/xml/network_security_config.xml | 9 + .../data/repository/AuthRepositoryTest.kt | 74 + .../xtreamtv/util/CategorySortUtilsTest.kt | 50 + .../tkgraph/xtreamtv/util/UrlBuilderTest.kt | 50 + build.gradle.kts | 6 + docs/ERROR-HANDLING.md | 153 ++ docs/SECURITY.md | 159 ++ env.md | 41 + gradle.properties | 6 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++ gradlew.bat | 93 ++ init_iptv_env.ps1 | 72 + settings.gradle.kts | 18 + 222 files changed, 22516 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 BUILD_INSTALL_MIBOX_USAGE_SORT.md create mode 100644 COMMANDES_BUILD_INSTALL_MIBOX.md create mode 100644 FAST_SCROLL_V10_1_COMPILE_FIX_NOTES.md create mode 100644 FAST_SCROLL_V10_NOTES.md create mode 100644 FIX_AUTH_RESPONSE_V22.md create mode 100644 FIX_CONTINUE_SEARCH_V4.md create mode 100644 FIX_CONTINUE_WATCHING_V12.md create mode 100644 FIX_FAST_CONTINUE_SEARCH_V11.md create mode 100644 FIX_HOME_SCROLL_COMPILE_V16.md create mode 100644 FIX_HOME_SCROLL_SIMPLE_V17.md create mode 100644 FIX_LIVE_FAST_CACHE_V8.md create mode 100644 FIX_LIVE_LOAD_V7.md create mode 100644 FIX_LOGIN_DIALOG_INPUT_V19.md create mode 100644 FIX_LOGIN_FOCUS_V20.md create mode 100644 FIX_LOGIN_INPUT_V18.md create mode 100644 FIX_LOGIN_SERVER_V21.md create mode 100644 FIX_LOGIN_SUCCESS_NAV_V23.md create mode 100644 FIX_PERFORMANCE_ROLLBACK_V9.md create mode 100644 FIX_RECENT_LIVE_V13.md create mode 100644 FIX_ROLLBACK_V5_STABLE_V10.md create mode 100644 FIX_USAGE_SORT_AND_APP_NAME.md create mode 100644 FIX_USAGE_SORT_V2.md create mode 100644 FIX_USAGE_SORT_V3.md create mode 100644 FIX_YOUTUBE_CATEGORY_V5.md create mode 100644 FOCUS_FIX_NOTES.md create mode 100644 FOCUS_FIX_V2_NOTES.md create mode 100644 FOCUS_FIX_V3_NOTES.md create mode 100644 FOCUS_FIX_V4_NOTES.md create mode 100644 FOCUS_FIX_V5_NOTES.md create mode 100644 FOCUS_FIX_V6_NOTES.md create mode 100644 FOCUS_FIX_V7_NOTES.md create mode 100644 FOCUS_FIX_V8_NOTES.md create mode 100644 MENU_HOME_V9_NOTES.md create mode 100644 README_INSTALLATION_YOUTUBE_MINIPLAYER.md create mode 100644 SPECIFICATIONS.md create mode 100644 Site web/README.md create mode 100644 Site web/assets/logo.svg create mode 100644 Site web/index.html create mode 100644 Site web/script.js create mode 100644 Site web/styles.css create mode 100644 TESTING.md create mode 100644 TV_OK.m3u create mode 100644 app/.gitignore create mode 100644 app/ARCHITECTURE.md create mode 100644 app/COMMANDES_BUILD_INSTALL_MIBOX.md create mode 100644 app/FOCUS_FIX_NOTES.md create mode 100644 app/FOCUS_FIX_V2_NOTES.md create mode 100644 app/FOCUS_FIX_V3_NOTES.md create mode 100644 app/SPECIFICATIONS.md create mode 100644 app/TESTING.md create mode 100644 app/app/build.gradle.kts create mode 100644 app/app/proguard-rules.pro create mode 100644 app/app/src/main/AndroidManifest.xml create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt create mode 100644 app/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt create mode 100644 app/app/src/main/res/drawable/ic_banner.png create mode 100644 app/app/src/main/res/drawable/launch_background.xml create mode 100644 app/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/app/src/main/res/mipmap-xhdpi/ic_banner.png create mode 100644 app/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/app/src/main/res/values-ar/strings.xml create mode 100644 app/app/src/main/res/values-fr/strings.xml create mode 100644 app/app/src/main/res/values/colors.xml create mode 100644 app/app/src/main/res/values/config.xml create mode 100644 app/app/src/main/res/values/strings.xml create mode 100644 app/app/src/main/res/values/themes.xml create mode 100644 app/app/src/main/res/xml/network_security_config.xml create mode 100644 app/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt create mode 100644 app/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt create mode 100644 app/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt create mode 100644 app/build.gradle.kts create mode 100644 app/docs/ERROR-HANDLING.md create mode 100644 app/docs/SECURITY.md create mode 100644 app/gradle.properties create mode 100644 app/gradle/wrapper/gradle-wrapper.jar create mode 100644 app/gradle/wrapper/gradle-wrapper.properties create mode 100644 app/gradlew create mode 100644 app/gradlew.bat create mode 100644 app/proguard-rules.pro create mode 100644 app/settings.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt create mode 100644 app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt create mode 100644 app/src/main/res/drawable/ic_banner.png create mode 100644 app/src/main/res/drawable/launch_background.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-fr/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/config.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt create mode 100644 app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt create mode 100644 app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt create mode 100644 build.gradle.kts create mode 100644 docs/ERROR-HANDLING.md create mode 100644 docs/SECURITY.md create mode 100644 env.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 init_iptv_env.ps1 create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd30e93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build/ +/captures +.externalNativeBuild/ +.cxx/ +*.apk +*.aab +*.hprof +local.properties +/app/build/ +/release/ +*.keystore +*.jks +*.json +!app/google-services.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8676de9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,136 @@ +# Architecture d'iMedia TV+ + +## Principes fondateurs + +- **Clean Architecture** simplifiée : UI → ViewModel → Repository → Data +- **Unidirectional Data Flow (UDF)** : les events montent, les states descendent +- **Single Activity** : une seule `MainActivity`, navigation par écrans Compose +- **Programmation réactive** : Kotlin Coroutines + Flow pour tout ce qui est async +- **Séparation claire** : chaque couche ignore l'existence des couches au-dessus d'elle + +## Flux de données complet + +```mermaid +sequenceDiagram + actor User as Utilisateur (Télécommande) + participant UI as Compose Screen + participant VM as ViewModel + participant Repo as Repository + participant API as Retrofit / Xtream + participant DB as Room (Cache) + participant Store as EncryptedSP + + User->>UI: Appuie sur OK (connexion) + UI->>VM: Event: Login(server, user, pass) + VM->>Repo: authRepository.login(server, user, pass) + Repo->>API: POST /player_api.php?username=... + API-->>Repo: Response { auth: "1", status: "Active" } + Repo->>Store: saveCredentials(encrypted) + Repo-->>VM: Result.Success(userInfo) + VM->>VM: _uiState = UiState.Success + VM-->>UI: StateFlow: HomeScreen data + UI-->>User: Affiche la liste des chaînes +``` + +## Diagramme de navigation des écrans + +```mermaid +stateDiagram-v2 + [*] --> SplashScreen + SplashScreen --> LoginScreen : Pas de session + SplashScreen --> HomeScreen : Session valide + + LoginScreen --> HomeScreen : Connexion OK + LoginScreen --> LoginScreen : Erreur → reste + + HomeScreen --> PlayerScreen : Sélection chaîne Live + HomeScreen --> ProgramGuideScreen : Appui Guide TV + HomeScreen --> ReplayGuideScreen : Appui Replay + HomeScreen --> VodScreen : Appui VOD + HomeScreen --> SeriesScreen : Appui Séries + + ProgramGuideScreen --> PlayerScreen : "Regarder" + ReplayGuideScreen --> PlayerScreen : "Revoir" + VodScreen --> PlayerScreen : Lecture film + SeriesScreen --> PlayerScreen : Lecture épisode + + PlayerScreen --> HomeScreen : Retour × 2 + PlayerScreen --> ProgramGuideScreen : Retour × 1 (si venu du guide) + PlayerScreen --> ReplayGuideScreen : Retour × 1 (si venu du replay) + + HomeScreen --> ConfirmQuit : Retour + ConfirmQuit --> [*] : Confirmer + ConfirmQuit --> HomeScreen : Annuler +``` + +## Organisation des ViewModels + +Chaque ViewModel expose : +- Un `StateFlow>` pour l'état de l'UI +- Des fonctions `onAction(X)` pour les intents utilisateur +- Une vue unique (`viewModelScope`) pour les coroutines + +```kotlin +class HomeViewModel( + private val liveRepo: LiveRepository, + private val epgRepo: EpgRepository, + private val favRepo: FavoritesRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + sealed interface Action { + data class SelectCategory(val id: Int) : Action + data class ToggleFavorite(val streamId: Int) : Action + data class PinChannel(val streamId: Int) : Action + object Refresh : Action + } + + fun onAction(action: Action) { + // ... + } +} +``` + +## Injection de dépendances + +Utilisation d'un **manual DI** simple via `AppContainer` (pas de framework lourd comme Hilt/Dagger pour un projet de cette taille). + +```kotlin +// AppContainer.kt +class AppContainer(context: Context) { + private val secureStorage = SecureStorage(context) + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(ErrorInterceptor()) + .addInterceptor(AuthInterceptor(secureStorage)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl("http://placeholder") // overridden per-call + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val xtreamApi = retrofit.create(XtreamApi::class.java) + + // Repositories + val authRepository = AuthRepository(xtreamApi, secureStorage) + val liveRepository = LiveRepository(xtreamApi) + val epgRepository = EpgRepository(xtreamApi, EpgDatabase.getInstance(context)) + // ... +} +``` + +## Décisions d'architecture + +| Décision | Alternative écartée | Raison | +|----------|-------------------|--------| +| Single Activity | Multi-Activity | Navigation + état simplifiés | +| Manual DI | Hilt / Koin | Pas de overhead pour projet solo | +| Room pour cache EPG | Simple JSON file | Requêtes typées, ACID, migrations | +| EncryptedSharedPreferences | DataStore | Chiffrement natif, API simple | +| ViewModel + StateFlow | MVVM + LiveData | Flow plus puissant, compose-ready | +| sealed interface pour actions | Callbacks | Testable, traçable, immutable | diff --git a/BUILD_INSTALL_MIBOX_USAGE_SORT.md b/BUILD_INSTALL_MIBOX_USAGE_SORT.md new file mode 100644 index 0000000..3bf2ca2 --- /dev/null +++ b/BUILD_INSTALL_MIBOX_USAGE_SORT.md @@ -0,0 +1,30 @@ +# Build + installation Mi Box - tri par utilisation + +Fonction ajoutée : +- les catégories LIVE / VOD / Séries sont triées avec les plus utilisées en premier ; +- les chaînes LIVE sont triées avec les plus utilisées en premier ; +- les films VOD et séries sont aussi triés par utilisation, parce qu'autant ne pas s'arrêter au milieu du gué comme un cahier des charges fatigué ; +- les compteurs sont stockés localement dans `EncryptedSharedPreferences`. + +## Commandes PowerShell depuis le dossier du projet + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" + +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +$env:Path = "$env:JAVA_HOME\bin;$env:Path" + +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk" +$env:Path = "$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:ANDROID_HOME\emulator;$env:Path" + +Set-Content -Path ".\local.properties" -Value "sdk.dir=$($env:LOCALAPPDATA.Replace('\','/'))/Android/Sdk" + +.\gradlew.bat :app:assembleDebug + +adb connect 192.168.1.8:5555 +adb devices +adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk +``` + +Si `adb connect` refuse : active le débogage réseau sur la Mi Box, vérifie que PC et Mi Box sont sur le même Wi-Fi, puis accepte l'autorisation RSA affichée sur la TV. diff --git a/COMMANDES_BUILD_INSTALL_MIBOX.md b/COMMANDES_BUILD_INSTALL_MIBOX.md new file mode 100644 index 0000000..7a7d499 --- /dev/null +++ b/COMMANDES_BUILD_INSTALL_MIBOX.md @@ -0,0 +1,103 @@ +# Commandes PowerShell - build APK et installation Mi Box + +## 1. Ouvrir le dossier du projet + +```powershell +cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES" +``` + +Adapte le chemin si tu extrais le ZIP ailleurs. Oui, Windows aime transformer les chemins en mini-épreuves administratives. + +## 2. Préparer Java et ADB, si nécessaire + +```powershell +$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" +``` + +Vérification : + +```powershell +java -version +adb version +``` + +## 3. Créer l'APK installable debug + +```powershell +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK généré : + +```powershell +.\app\build\outputs\apk\debug\app-debug.apk +``` + +## 4. Connecter la Mi Box en ADB réseau + +Sur la Mi Box, active d'abord : + +- Options développeur +- Débogage USB / Débogage réseau +- Autoriser la connexion ADB quand la télévision affiche la demande + +Puis sur le PC : + +```powershell +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb devices +``` + +Si `adb devices` affiche `unauthorized`, regarde l'écran de la Mi Box et valide l'autorisation RSA. Cette étape existe pour rappeler que même les télévisions veulent signer des contrats. + +## 5. Installer l'APK sur la Mi Box + +```powershell +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +Si Android refuse à cause d'une version déjà installée : + +```powershell +adb install -r -d ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +Si le paquet est corrompu ou bloqué par une ancienne installation : + +```powershell +adb uninstall ma.tkgraph.xtreamtv +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +## 6. Lancer l'application après installation + +```powershell +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` + +Alternative : + +```powershell +adb shell monkey -p ma.tkgraph.xtreamtv 1 +``` + +## 7. Commande complète rapide + +```powershell +cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES" +$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` diff --git a/FAST_SCROLL_V10_1_COMPILE_FIX_NOTES.md b/FAST_SCROLL_V10_1_COMPILE_FIX_NOTES.md new file mode 100644 index 0000000..4abd77f --- /dev/null +++ b/FAST_SCROLL_V10_1_COMPILE_FIX_NOTES.md @@ -0,0 +1,39 @@ +# V10.1 - Correction compilation FastScrollKeys + +Correction du fichier : + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt` + +## Problème corrigé + +La V10 utilisait l'import : + +```kotlin +import androidx.compose.ui.input.key.nativeKeyEvent +``` + +Sur la version Compose utilisée par le projet, cet import n'est pas disponible, ce qui provoquait : + +```text +Unresolved reference 'nativeKeyEvent' +``` + +## Nouvelle approche + +La V10.1 ne dépend plus de `nativeKeyEvent` dans `FastScrollKeys.kt`. + +Elle utilise : + +- `Key.DirectionUp` +- `Key.DirectionDown` +- des touches Android TV converties en `Key(...)` pour `PAGE_UP`, `PAGE_DOWN`, `CHANNEL_UP`, `CHANNEL_DOWN` +- un compteur de répétition pour détecter les appuis longs Haut/Bas + +## Comportement attendu + +- Appui long Haut : remonter au début de la liste +- Appui long Bas : descendre à la fin de la liste +- Channel+ / PageUp : remonter par page +- Channel- / PageDown : descendre par page + +Base : V10, elle-même basée sur V9 + focus V8 stable. diff --git a/FAST_SCROLL_V10_NOTES.md b/FAST_SCROLL_V10_NOTES.md new file mode 100644 index 0000000..d692d3b --- /dev/null +++ b/FAST_SCROLL_V10_NOTES.md @@ -0,0 +1,37 @@ +# V10 - Navigation rapide dans les longues listes + +Base utilisée : `12-IPTV_Android_TV_HERMES_focus_fix_v9_menu_home.zip`. + +## Objectif + +Éviter de devoir défiler manuellement des centaines de lignes avec la télécommande. + +## Raccourcis ajoutés + +| Touche télécommande | Résultat | +|---|---| +| Appui long Haut | Aller directement au début de la liste | +| Appui long Bas | Aller directement à la fin de la liste | +| Channel+ / PageUp | Remonter d'environ 15 éléments | +| Channel- / PageDown | Descendre d'environ 15 éléments | + +## Écrans concernés + +- TV : liste des chaînes +- VOD : grille des films +- Séries : grille des séries +- Détail série : liste des épisodes + +## Fichiers modifiés + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt` + +## Nouveau fichier + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt` + +## Remarque importante + +Sur l'écran TV, la navigation rapide est désactivée pendant le mode actions Replay / Épingler / Favori, afin de ne pas casser la logique focus validée en V8/V9. diff --git a/FIX_AUTH_RESPONSE_V22.md b/FIX_AUTH_RESPONSE_V22.md new file mode 100644 index 0000000..22841b7 --- /dev/null +++ b/FIX_AUTH_RESPONSE_V22.md @@ -0,0 +1,30 @@ +# Fix V22 - Auth response parsing + +Base: V21 login-server-fix. + +Problem: +- The app reached the Xtream server, but showed "Identifiants invalides ou serveur incorrect". +- Many Xtream servers return auth/status inside `user_info`, for example: + `{"user_info":{"auth":1,"status":"Active"}, ...}` +- The previous login logic checked `auth` at the root level, which is often absent. +- Result: false invalid-login message even when credentials are correct. + +Fix: +- AuthRepository now reads: + - `response.userInfo.auth` first + - fallback to root `response.auth` + - `response.userInfo.status` first + - fallback to root `response.status` +- It accepts `auth = 1` and `auth = true`. +- It keeps the V21 temporary server save fix. +- It restores previous config on failed login. + +Scope: +- Only AuthRepository.kt and version number are changed. +- Login input dialog/focus from V19/V20 stays. +- Server URL normalization from V21 stays. +- No changes to LIVE, replay, player, search, YouTube, recent LIVE, continue watching, or home scroll. + +Version: +- versionCode = 22 +- versionName = 2.0.22-auth-response-fix diff --git a/FIX_CONTINUE_SEARCH_V4.md b/FIX_CONTINUE_SEARCH_V4.md new file mode 100644 index 0000000..e0811b1 --- /dev/null +++ b/FIX_CONTINUE_SEARCH_V4.md @@ -0,0 +1,43 @@ +# Version 2.0.4-continue-search + +Base utilisée : 2.0.3-usage-sort-v3 validée. + +## Modifications + +- Nom affiché de l'application remis à `iMedia TV+` dans les ressources Android. +- Ajout d'une entrée `RECHERCHE` dans le menu principal. +- Ajout d'un écran `Recherche globale` qui cherche dans : + - LIVE ; + - VOD ; + - Séries. +- Ajout d'une section `Continuer à regarder` sur le menu principal. +- La reprise utilise les progressions locales existantes : + - `vod:` ; + - `series::`. +- Ajout d'un horodatage local `:lastWatched` pour trier les reprises par dernier visionnage. + +## Notes + +- Pour les anciennes progressions sans horodatage, les contenus peuvent apparaître sans ordre précis au début. +- Après une nouvelle lecture, l'ordre devient fiable grâce à `lastWatched`. +- La recherche séries lance le premier épisode de la série trouvée. +- La recherche VOD lance le film directement. +- La recherche LIVE lance la chaîne directement. + +## Commandes + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" +.\gradlew.bat clean :app:assembleDebug +adb connect 192.168.1.8:5555 +adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk +adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1 +``` + +Mi Box 2 : + +```powershell +adb connect 192.168.1.56:5555 +adb -s 192.168.1.56:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk +adb -s 192.168.1.56:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1 +``` diff --git a/FIX_CONTINUE_WATCHING_V12.md b/FIX_CONTINUE_WATCHING_V12.md new file mode 100644 index 0000000..f7725b5 --- /dev/null +++ b/FIX_CONTINUE_WATCHING_V12.md @@ -0,0 +1,26 @@ +# Correctif V12 - Continuer le visionnage + +Base : `2.0.11-fast-continue-search`. + +Version : `2.0.12-continue-fix`. + +## Problème constaté + +Après la V11, la recherche globale rend bien la main instantanément, mais le volet `Continuer à regarder` peut ne plus s'afficher. + +Cause : la V11 utilisait uniquement les nouvelles métadonnées locales ajoutées au moment de la lecture. Les progressions créées avant cette version, ou certains lancements qui ne sauvegardaient pas encore toutes les informations de reprise, n'étaient donc plus visibles dans le volet. + +## Correction + +- Le volet `Continuer à regarder` lit d'abord les métadonnées locales rapides. +- Si d'anciennes progressions existent sans métadonnées, l'app reconstruit ces métadonnées une fois depuis le catalogue VOD/Séries. +- Les métadonnées reconstruites sont sauvegardées pour que les prochains affichages soient rapides. +- L'URL de lecture réelle est maintenant sauvegardée dans les métadonnées, notamment pour les films VOD. +- Le volet affiche un état `Chargement des reprises…` au lieu de disparaître silencieusement. + +## Non modifié + +- Chargement LIVE non modifié. +- Lecture vidéo non modifiée côté buffering. +- Recherche globale V11 conservée. +- Catégorie YouTube conservée. diff --git a/FIX_FAST_CONTINUE_SEARCH_V11.md b/FIX_FAST_CONTINUE_SEARCH_V11.md new file mode 100644 index 0000000..73f7172 --- /dev/null +++ b/FIX_FAST_CONTINUE_SEARCH_V11.md @@ -0,0 +1,49 @@ +# FIX_FAST_CONTINUE_SEARCH_V11 + +Version: `2.0.11-fast-continue-search` + +Base: rollback stable V10 basé sur V5. + +## Objectif + +Corriger les deux lenteurs mesurées : + +- `Continuer à regarder` ~24 s +- `Recherche globale ALL` ~30 s + +sans toucher au chargement LIVE, au replay, ni au lecteur. + +## Changements + +### Continuer à regarder + +Avant, le menu principal rechargeait le catalogue VOD complet, le catalogue Séries complet, puis les détails de séries pour reconstruire les éléments en cours. + +Maintenant : + +- le lecteur sauvegarde des métadonnées minimales au moment de la lecture ; +- le menu principal lit ces métadonnées locales ; +- aucun catalogue VOD/Séries n'est chargé pour afficher `Continuer à regarder`. + +Note : les anciennes progressions créées avant cette version n'ont pas encore ces métadonnées. Elles réapparaissent après avoir relancé le film/épisode au moins une fois. + +### Recherche globale + +Avant, l'écran chargeait LIVE + VOD + Séries dès l'ouverture. + +Maintenant : + +- l'écran ne charge rien au démarrage ; +- l'utilisateur choisit `LIVE`, `VOD`, `SÉRIES` ou `ALL` ; +- il tape la recherche puis clique sur `Lancer` ; +- les résultats sont affichés progressivement ; +- LIVE devient le filtre par défaut pour éviter le chargement massif inutile. + +## Fichiers modifiés + +- `app/build.gradle.kts` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt` diff --git a/FIX_HOME_SCROLL_COMPILE_V16.md b/FIX_HOME_SCROLL_COMPILE_V16.md new file mode 100644 index 0000000..5e965cd --- /dev/null +++ b/FIX_HOME_SCROLL_COMPILE_V16.md @@ -0,0 +1,14 @@ +# Fix V16 - Home scroll compile fix + +Base: V14 recent-live-visible. + +Changes: +- Adds vertical scrolling on the home screen. +- Keeps recent LIVE row above Continue Watching. +- Scrolls the home screen when focus enters recent LIVE / continue watching rows. +- Removes unsupported `rememberBringIntoViewRequester`, replaced by `rememberScrollState` + `animateScrollTo`. +- Does not change LIVE loading, replay, player, YouTube or global search logic. + +Version: +- versionCode = 16 +- versionName = 2.0.16-home-scroll-compile-fix diff --git a/FIX_HOME_SCROLL_SIMPLE_V17.md b/FIX_HOME_SCROLL_SIMPLE_V17.md new file mode 100644 index 0000000..87589ea --- /dev/null +++ b/FIX_HOME_SCROLL_SIMPLE_V17.md @@ -0,0 +1,12 @@ +# Fix V17 - Home vertical scroll simple + +Base: V16. + +Fix: +- Removes `rememberCoroutineScope` dependency that failed to compile on the user's project. +- Keeps `verticalScroll(rememberScrollState())` so the home screen can scroll vertically with TV remote navigation. +- Does not change LIVE, replay, player, global search, YouTube, recent LIVE data, or continue watching data. + +Version: +- versionCode = 17 +- versionName = 2.0.17-home-scroll-simple diff --git a/FIX_LIVE_FAST_CACHE_V8.md b/FIX_LIVE_FAST_CACHE_V8.md new file mode 100644 index 0000000..e901aca --- /dev/null +++ b/FIX_LIVE_FAST_CACHE_V8.md @@ -0,0 +1,45 @@ +# Correctif V8 - LIVE plus rapide avec cache + +Version : `2.0.8-live-fast-cache` + +## Problème constaté +Après le correctif V7, l'écran LIVE chargeait correctement, mais plus lentement. + +La V7 avait rendu le chargement plus sûr en lançant les opérations dans l'ordre : +1. catégories LIVE ; +2. chaînes LIVE. + +Sur de gros catalogues, surtout avec `All`, cela ajoutait une attente visible. + +## Correctif V8 + +### 1. Chargement LIVE en parallèle sécurisé +L'écran LIVE relance maintenant : +- catégories LIVE ; +- chaînes LIVE ; + +en parallèle, mais avec des verrous/cache dans le repository pour éviter le bug d'écran vide. + +### 2. Cache mémoire Xtream +`LiveRepository` garde en mémoire : +- les catégories LIVE Xtream ; +- les chaînes LIVE par catégorie ; +- les chaînes LIVE `All`. + +Résultat : revenir sur LIVE ne recharge plus inutilement le catalogue. + +### 3. Cache M3U disque court +`M3uRepository` garde maintenant une copie locale du fichier M3U pendant 6 heures. + +Résultat : après un premier chargement, l'ouverture LIVE suivante peut parser le fichier local au lieu de retélécharger toute la playlist. + +### 4. Invalidation propre +À la déconnexion, les caches LIVE/M3U sont vidés. + +## Fichiers modifiés +- `app/build.gradle.kts` +- `app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt` diff --git a/FIX_LIVE_LOAD_V7.md b/FIX_LIVE_LOAD_V7.md new file mode 100644 index 0000000..4284bd7 --- /dev/null +++ b/FIX_LIVE_LOAD_V7.md @@ -0,0 +1,26 @@ +# Correctif V7 - LIVE ne charge pas directement + +Version: `2.0.7-live-load-fix` + +## Problème constaté + +Après la version `2.0.6-search-on-demand`, l'écran LIVE pouvait rester vide alors que la recherche globale retrouvait bien les chaînes. + +## Cause probable + +L'écran LIVE lançait le chargement des catégories et des chaînes en parallèle dès l'initialisation du ViewModel. Sur certaines configurations, surtout avec M3U/cache sur Mi Box, ce démarrage parallèle peut laisser l'écran avec un état vide ou non relancé. + +## Correctif + +- Initialisation LIVE passée en `UiState.Loading` au lieu de `Success(emptyList())`. +- Chargement initial LIVE rendu séquentiel : catégories puis chaînes. +- Ajout de `ensureLoaded()` appelé à l'ouverture de l'écran LIVE pour relancer si l'écran est vide. +- Les erreurs ne sont plus masquées en liste vide : elles sont affichées. +- Sécurisation du cache M3U avec `Mutex` pour éviter deux fetchs parallèles du même catalogue. + +## Fichiers modifiés + +- `app/build.gradle.kts` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` diff --git a/FIX_LOGIN_DIALOG_INPUT_V19.md b/FIX_LOGIN_DIALOG_INPUT_V19.md new file mode 100644 index 0000000..7dea0f3 --- /dev/null +++ b/FIX_LOGIN_DIALOG_INPUT_V19.md @@ -0,0 +1,23 @@ +# Fix V19 - Login dialog input + +Base: V17 home-scroll-simple. + +Problem: +- Direct text input on Android TV / Mi Box login fields is unreliable. +- V18 native inline EditText still did not solve text entry on the target device. + +Fix: +- Login fields are no longer typed directly. +- Each field is a TV-focusable card. +- Press OK on a field to open a native Android input dialog with an EditText. +- The dialog requests focus and explicitly opens the soft keyboard. +- Confirm with OK in the dialog to copy the entered text into the login screen. +- DPAD navigation between fields remains. + +Scope: +- Only LoginScreen.kt and version number are changed. +- LIVE, replay, player, global search, YouTube, recent LIVE, continue watching and home scroll are untouched. + +Version: +- versionCode = 19 +- versionName = 2.0.19-login-dialog-input diff --git a/FIX_LOGIN_FOCUS_V20.md b/FIX_LOGIN_FOCUS_V20.md new file mode 100644 index 0000000..9eb05b6 --- /dev/null +++ b/FIX_LOGIN_FOCUS_V20.md @@ -0,0 +1,20 @@ +# Fix V20 - Login focus fix + +Base: V19 login-dialog-input. + +Problem: +- Text input works in the native dialog, but DPAD navigation skips the second field. +- The cause is DPAD handling on KeyUp: the system can already move focus on KeyDown, then our custom handler moves again on KeyUp. One press can jump two fields. + +Fix: +- DPAD focus handling now consumes KeyDown instead of KeyUp. +- This prevents double focus movement. +- Help text was updated to explain: arrows change fields, OK opens input. + +Scope: +- Only LoginScreen.kt and version number are changed. +- LIVE, replay, player, global search, YouTube, recent LIVE, continue watching and home scroll are untouched. + +Version: +- versionCode = 20 +- versionName = 2.0.20-login-focus-fix diff --git a/FIX_LOGIN_INPUT_V18.md b/FIX_LOGIN_INPUT_V18.md new file mode 100644 index 0000000..29005d1 --- /dev/null +++ b/FIX_LOGIN_INPUT_V18.md @@ -0,0 +1,17 @@ +# Fix V18 - Login input fix + +Base: V17 home-scroll-simple. + +Problem: +- On some Android TV / Mi Box devices, Compose `OutlinedTextField` receives focus but does not reliably open text entry. + +Fix: +- Replaces login text fields with native Android `EditText` through `AndroidView`. +- Explicitly opens the soft keyboard on focus/click. +- Keeps DPAD navigation between fields. +- Only changes LoginScreen and version number. +- Does not touch LIVE, replay, player, global search, YouTube, recent LIVE, or continue watching. + +Version: +- versionCode = 18 +- versionName = 2.0.18-login-input-fix diff --git a/FIX_LOGIN_SERVER_V21.md b/FIX_LOGIN_SERVER_V21.md new file mode 100644 index 0000000..91ecc35 --- /dev/null +++ b/FIX_LOGIN_SERVER_V21.md @@ -0,0 +1,22 @@ +# Fix V21 - Login server connection + +Base: V20 login-focus-fix. + +Problem: +- During first Xtream login, the Retrofit base URL interceptor reads the server URL from SecureStorage. +- But the server URL was only saved after successful login. +- Result: first login could call the placeholder localhost and show "Cannot reach server", even when the server works. + +Fix: +- AuthRepository now normalizes and temporarily saves the Xtream config before calling player_api.php. +- If login fails, previous config is restored, or only connection config is cleared while preserving local data. +- XtreamBaseUrlInterceptor now also respects http/https scheme and normalizes host/port. +- Server URL, username, password and M3U URL are trimmed before use. + +Scope: +- Network login path only. +- No changes to LIVE, replay, player, global search, YouTube, recent LIVE, continue watching, or home scroll. + +Version: +- versionCode = 21 +- versionName = 2.0.21-login-server-fix diff --git a/FIX_LOGIN_SUCCESS_NAV_V23.md b/FIX_LOGIN_SUCCESS_NAV_V23.md new file mode 100644 index 0000000..3f68f4d --- /dev/null +++ b/FIX_LOGIN_SUCCESS_NAV_V23.md @@ -0,0 +1,24 @@ +# Fix V23 - Login success navigation + +Base: V22 auth-response-fix. + +Problem: +- Login could succeed and save the configuration, but the app stayed on LoginScreen. +- In MainActivity, `hasSession` was computed once as a normal local value, not a Compose state. +- After a successful login, the parent navigation was not notified and did not recompose to MainMenu. + +Fix: +- `AppNavigation` now keeps `hasSession` as mutable Compose state. +- `LoginScreen` now accepts `onLoginSuccess`. +- `LoginScreen` triggers `onLoginSuccess()` when `uiState` becomes `UiState.Success`. +- On success, navigation goes to `NavState.MainMenu`. + +Scope: +- Only MainActivity.kt, LoginScreen.kt and version number changed. +- All V22 login server/auth parsing fixes remain. +- Login input dialog/focus fixes remain. +- No changes to LIVE, replay, player, global search, YouTube, recent LIVE, continue watching, or home scroll. + +Version: +- versionCode = 23 +- versionName = 2.0.23-login-success-nav diff --git a/FIX_PERFORMANCE_ROLLBACK_V9.md b/FIX_PERFORMANCE_ROLLBACK_V9.md new file mode 100644 index 0000000..e0734d0 --- /dev/null +++ b/FIX_PERFORMANCE_ROLLBACK_V9.md @@ -0,0 +1,30 @@ +# Correctif V9 - rollback performance + +Version: `2.0.9-performance-rollback` + +## Objectif + +Annuler le correctif V8 `2.0.8-live-fast-cache`, car il ralentissait toute l’application : LIVE, replay et démarrage des flux. + +## Ce qui est retiré + +- Cache disque M3U 6 heures. +- Cache mémoire Xtream ajouté en V8. +- Chargement parallèle catégories + chaînes LIVE ajouté en V8. +- Invalidation globale des caches LIVE/M3U ajoutée en V8. + +## Ce qui est conservé + +Cette V9 repart de la V7, donc elle garde : + +- Tri par utilisation V3. +- Continuer à regarder V4. +- Recherche globale à la demande V6. +- Catégorie YouTube V5. +- Correctif écran LIVE vide V7. + +## Pourquoi + +La V8 a ajouté trop de logique de cache/chargement parallèle dans les repositories LIVE/M3U. Sur Mi Box, cela peut créer de la contention réseau/CPU/disque et ralentir le lancement des chaînes et du replay. + +La V9 revient à un comportement plus simple et stable. diff --git a/FIX_RECENT_LIVE_V13.md b/FIX_RECENT_LIVE_V13.md new file mode 100644 index 0000000..6333459 --- /dev/null +++ b/FIX_RECENT_LIVE_V13.md @@ -0,0 +1,26 @@ +# V13 - Dernières chaînes LIVE + +Base: `2.0.12-continue-fix` + +Version: `2.0.13-recent-live` + +## Ajout + +- Nouvelle ligne `Dernières chaînes LIVE` dans le menu principal, affichée au-dessus de `Continuer à regarder`. +- Les chaînes LIVE lancées depuis l'écran TV sont sauvegardées localement. +- Les chaînes LIVE lancées depuis la recherche globale sont sauvegardées localement. +- Le zapping gauche/droite dans le lecteur met aussi à jour les dernières chaînes si le flux est bien LIVE. +- Lecture directe depuis cette ligne sans appel serveur ni chargement catalogue. + +## Fichiers modifiés + +- `app/build.gradle.kts` +- `app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt` diff --git a/FIX_ROLLBACK_V5_STABLE_V10.md b/FIX_ROLLBACK_V5_STABLE_V10.md new file mode 100644 index 0000000..9428eb8 --- /dev/null +++ b/FIX_ROLLBACK_V5_STABLE_V10.md @@ -0,0 +1,22 @@ +# FIX_ROLLBACK_V5_STABLE_V10 + +Version: 2.0.10-v5-stable-rollback + +Base code: 2.0.5-youtube-category, dernière version signalée comme fluide avant les régressions introduites par la recherche à la demande V6 puis les correctifs V7/V8/V9. + +Objectif: +- Revenir au comportement stable V5. +- Garder un versionCode supérieur (10) pour permettre l’installation par-dessus V6/V7/V8/V9 sans erreur de downgrade. + +Contenu conservé depuis V5: +- Tri par utilisation V3. +- Continuer à regarder. +- Recherche globale classique. +- Catégorie YouTube. +- Nom iMedia TV+. + +Non inclus volontairement: +- Recherche globale à la demande V6. +- Correctifs LIVE V7. +- Cache/optimisation LIVE V8. +- Rollback V9 basé sur V7. diff --git a/FIX_USAGE_SORT_AND_APP_NAME.md b/FIX_USAGE_SORT_AND_APP_NAME.md new file mode 100644 index 0000000..963cfb9 --- /dev/null +++ b/FIX_USAGE_SORT_AND_APP_NAME.md @@ -0,0 +1,44 @@ +# Correctif tri par utilisation + nom de l'application + +## Pourquoi l'app apparaît comme `ma.tkgraph.xtreamtv` + +`ma.tkgraph.xtreamtv` est l'`applicationId` Android, c'est-à-dire l'identifiant technique du package installé. +Ce n'est pas censé être le nom public de l'application. Le nom affiché vient de `@string/app_name`. + +Dans ce correctif, le nom affiché reste défini dans : + +```text +app/src/main/res/values/strings.xml +app/src/main/res/values-fr/strings.xml +app/src/main/res/values-ar/strings.xml +``` + +Le nom affiché est maintenant `HERMES IPTV`. + +Le package technique reste `ma.tkgraph.xtreamtv` pour ne pas perdre les données locales de l'application. + +## Problème corrigé + +Le premier ZIP avait placé les fichiers modifiés dans un mauvais sous-dossier `app/app/...`. +Le module réellement compilé est `app/...`, donc Gradle recompilait l'ancien code. Résultat : build OK, mais le tri ne pouvait pas marcher. + +Ce ZIP remet les modifications dans le bon module : + +```text +app/src/main/java/ma/tkgraph/xtreamtv/... +``` + +## Commandes + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" + +.\gradlew.bat :app:assembleDebug +adb connect 192.168.1.8:5555 +adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk +adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1 +``` + +## Important + +Le tri commence à devenir visible après utilisation : ouvre des catégories/chaînes plusieurs fois, reviens à l'écran, puis recharge l'écran si nécessaire. diff --git a/FIX_USAGE_SORT_V2.md b/FIX_USAGE_SORT_V2.md new file mode 100644 index 0000000..7b919f5 --- /dev/null +++ b/FIX_USAGE_SORT_V2.md @@ -0,0 +1,22 @@ +# Correctif V2 - tri par utilisation sans casser le lecteur + +Cette version corrige l'erreur précédente : les fichiers du lecteur `PlayerScreen.kt` et `PlayerViewModel.kt` ne sont plus remplacés par une ancienne version. + +Fonctions ajoutées : + +- tri des catégories Live/VOD/Séries par utilisation ; +- tri des chaînes Live par utilisation ; +- tri des films VOD par utilisation ; +- tri des séries par utilisation ; +- nom affiché de l'application : `HERMES IPTV` ; +- package technique conservé : `ma.tkgraph.xtreamtv`. + +Commandes : + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" +.\gradlew.bat :app:assembleDebug +adb connect 192.168.1.8:5555 +adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk +adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1 +``` diff --git a/FIX_USAGE_SORT_V3.md b/FIX_USAGE_SORT_V3.md new file mode 100644 index 0000000..d277372 --- /dev/null +++ b/FIX_USAGE_SORT_V3.md @@ -0,0 +1,32 @@ +# FIX_USAGE_SORT_V3 + +Correctif V3 du tri par utilisation. + +## Problème constaté + +La V2 enregistrait les compteurs dans le stockage, mais l'interface gardait souvent les listes déjà chargées. +Résultat : après un clic ou une lecture, le compteur pouvait être incrémenté sans que l'ordre visible change tout de suite. + +## Correctif appliqué + +- `SecureStorage.incrementUsageCounter(...)` utilise maintenant `commit()` au lieu de `apply()` pour rendre l'incrément disponible immédiatement. +- `HomeViewModel` retrie immédiatement : + - les catégories LIVE après sélection ; + - les chaînes LIVE après lecture. +- `VodViewModel` retrie immédiatement : + - les catégories VOD après sélection ; + - les films après lecture. +- `SeriesViewModel` retrie immédiatement : + - les catégories Séries après sélection ; + - les séries après ouverture. +- Les barres horizontales de catégories LIVE / VOD / Séries reviennent au début après changement d'ordre, pour que le résultat soit visible de gauche à droite. + +## Test recommandé + +1. Installer l'APK V3. +2. Ouvrir une catégorie plusieurs fois. +3. Revenir à la ligne des catégories : elle doit remonter vers le début. +4. Lancer une chaîne ou un film plusieurs fois. +5. Revenir à la liste : l'élément utilisé doit remonter dans la liste. + +Note : `All` et `Favorites` restent volontairement devant car ce sont des boutons spéciaux. diff --git a/FIX_YOUTUBE_CATEGORY_V5.md b/FIX_YOUTUBE_CATEGORY_V5.md new file mode 100644 index 0000000..14ce97e --- /dev/null +++ b/FIX_YOUTUBE_CATEGORY_V5.md @@ -0,0 +1,35 @@ +# iMedia TV+ - V5 YouTube category + +Version: `2.0.5-youtube-category` + +## Objectif +Ajouter une catégorie `YOUTUBE` dans le menu principal pour gérer des vidéos YouTube indépendamment de LIVE/VOD/Séries. + +## Fonctionnalités ajoutées +- Nouvelle tuile `YOUTUBE` dans le menu principal. +- Nouvel écran `YoutubeScreen`. +- Ajout de vidéos par lien complet ou code vidéo YouTube. +- Titre optionnel lors de l'ajout. +- Sauvegarde locale des vidéos dans `SharedPreferences`. +- Liste des vidéos enregistrées avec miniature YouTube quand un `videoId` est détecté. +- Suppression d'une vidéo enregistrée. +- Lecteur YouTube plein écran via `WebView`. +- Commandes dans le lecteur: + - OK / bouton Play/Pause : pause ou reprise. + - Gauche : -10 secondes. + - Droite : +10 secondes. + - Retour : fermer le lecteur. +- Fallback vers le site YouTube si l'embed n'est pas possible. + +## Fichiers principaux +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt` +- `app/build.gradle.kts` + +## Notes +Certaines vidéos YouTube peuvent refuser l'intégration `embed`. Dans ce cas l'écran tente d'ouvrir l'URL YouTube normale dans la WebView. diff --git a/FOCUS_FIX_NOTES.md b/FOCUS_FIX_NOTES.md new file mode 100644 index 0000000..e781235 --- /dev/null +++ b/FOCUS_FIX_NOTES.md @@ -0,0 +1,28 @@ +# Correctifs focus Android TV - Hermes + +## Fichiers modifiés + +- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt` + +## Changements principaux + +1. La carte chaîne de l'écran Live est maintenant cliquable sur toute sa largeur. +2. Le `Text` du nom de chaîne n'est plus le seul élément cliquable. +3. Le focus est restauré sur la chaîne après retour depuis le player. +4. Le premier item de la liste reçoit le focus après chargement initial. +5. Le long appui OK ouvre la recherche uniquement sur l'écran Live, plus globalement dans `MainActivity`. +6. Les boutons Replay, Pin, Favori, Back, Guide et Replay ont un contour de focus plus visible. +7. Les champs Login ont une navigation DPAD haut/bas explicite pour éviter le curseur bloqué dans les zones de texte. +8. Dans le Player, OK affiche/masque l'overlay. + +## Note de compilation + +Le projet n'a pas été compilé dans cet environnement, car le wrapper Gradle doit télécharger `gradle-9.1.0-bin.zip` depuis `services.gradle.org`, et l'environnement d'analyse n'a pas d'accès réseau. Compile le projet sur ta machine Windows avec les commandes de `COMMANDES_BUILD_INSTALL_MIBOX.md`. diff --git a/FOCUS_FIX_V2_NOTES.md b/FOCUS_FIX_V2_NOTES.md new file mode 100644 index 0000000..40c2fdf --- /dev/null +++ b/FOCUS_FIX_V2_NOTES.md @@ -0,0 +1,28 @@ +# Correctif focus V2 - appui long OK + +## Changement appliqué + +Le déclenchement de la recherche par appui long sur OK a été supprimé dans `HomeScreen.kt`. + +Avant : +- Appui long OK, première séquence : ouverture de la recherche. +- Appui long OK ensuite : accès aux actions de la chaîne. + +Maintenant : +- La recherche reste accessible par le bouton `Rechercher…` dans le header avec les boutons directionnels. +- L'appui long OK n'est plus intercepté globalement par `HomeScreen` pour ouvrir la recherche. +- Le comportement d'accès aux boutons/actions de la chaîne n'est plus précédé par l'ouverture forcée de la recherche. + +## Fichier modifié + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` + +## Détail technique + +Suppression du bloc : + +- `onPreviewKeyEvent` au niveau du `Box` principal. +- variable `okLongPressHandled`. +- import `android.view.KeyEvent as AndroidKeyEvent`. + +La recherche est maintenant volontairement limitée au champ/bouton de recherche visible dans le header. diff --git a/FOCUS_FIX_V3_NOTES.md b/FOCUS_FIX_V3_NOTES.md new file mode 100644 index 0000000..dbbf005 --- /dev/null +++ b/FOCUS_FIX_V3_NOTES.md @@ -0,0 +1,69 @@ +# Focus Fix V3 - Appui long OK sans déclenchement Replay + +## Problème corrigé + +Sur Android TV / Mi Box, l'appui long sur OK activait le mode actions, puis le relâchement du même OK pouvait être reçu par le premier bouton d'action, généralement `Replay`. + +Résultat : l'utilisateur voulait seulement accéder aux actions de la chaîne, mais l'application ouvrait Replay automatiquement. + +## Correction appliquée + +Fichier modifié : + +```text +app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt +``` + +Changements principaux : + +1. La carte chaîne intercepte elle-même OK : + - OK court = lire la chaîne. + - OK long = ouvrir le mode actions. + - Le relâchement OK après un appui long est consommé, donc il ne déclenche plus `Replay`. + +2. Le mode actions n'envoie plus automatiquement le focus sur `Replay`. + - Après appui long OK, la ligne passe en mode actions. + - L'utilisateur appuie ensuite sur droite pour aller vers `Replay`, `Pin`, ou `Favori`. + - Cela évite les faux clics liés à un appui long difficile à doser. + +3. Les actions `Replay`, `Pin`, `Favori` ne sont focusables/clickables que lorsque le mode actions est actif. + +4. BACK ferme le mode actions et remet le focus sur la carte chaîne. + +## Nouvelle logique d'utilisation + +- OK court sur une chaîne : lancer la chaîne. +- OK long sur une chaîne : ouvrir les actions. +- Droite : entrer dans les boutons de la chaîne. +- Gauche depuis les boutons : revenir à la carte chaîne. +- BACK : fermer le mode actions. + +## Compilation + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" + +$env:JAVA_HOME="D:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" + +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK : + +```powershell +.\app\build\outputs\apk\debug\app-debug.apk +``` + +## Installation Mi Box + +```powershell +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb devices +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` diff --git a/FOCUS_FIX_V4_NOTES.md b/FOCUS_FIX_V4_NOTES.md new file mode 100644 index 0000000..c26da85 --- /dev/null +++ b/FOCUS_FIX_V4_NOTES.md @@ -0,0 +1,27 @@ +# Focus Fix V4 + +## Objectif + +Restaurer un focus visuel clair sur les cartes chaînes après la correction V3. + +## Problème constaté + +La V3 empêchait bien le lancement automatique de Replay après un appui long OK, mais le contour de focus sur les chaînes pouvait disparaître. + +Cause probable : l'ordre des modifiers Compose. Sur Android TV, `onFocusChanged` doit être placé avant `focusable()` pour capter correctement l'état de focus de la carte. + +## Correction + +Dans `HomeScreen.kt`, la carte chaîne utilise maintenant un état de focus explicite : + +- `onFocusChanged { isFocused = it.isFocused || it.hasFocus }` avant `focusable()` ; +- bordure orange visible quand la carte a le focus ; +- bordure blanche quand le mode actions est ouvert ; +- conservation de la correction V3 : l'appui long OK ouvre les actions sans cliquer automatiquement sur Replay. + +## Résultat attendu + +- OK court : lance la chaîne. +- OK long : ouvre le mode actions. +- La carte chaîne sélectionnée affiche à nouveau un contour visible. +- Replay ne se déclenche plus automatiquement au relâchement du bouton OK. diff --git a/FOCUS_FIX_V5_NOTES.md b/FOCUS_FIX_V5_NOTES.md new file mode 100644 index 0000000..0c7ce63 --- /dev/null +++ b/FOCUS_FIX_V5_NOTES.md @@ -0,0 +1,24 @@ +# Focus Fix V5 - Navigation actions chaîne + +## Correction principale + +La navigation dans le mode actions d'une chaîne est maintenant contrôlée explicitement. + +Avant : +- Depuis `Replay`, la touche gauche pouvait envoyer le focus vers `Paramètres`. +- La touche droite depuis `Replay` ne permettait pas d'aller vers `Épingler` et `Favori`. +- La touche bas pouvait sortir brutalement de la zone actions. + +Maintenant : +- `OK long` sur une chaîne ouvre le mode actions. +- `Droite` depuis la carte chaîne place le focus sur `Replay`. +- `Droite` depuis `Replay` va vers `Épingler`. +- `Droite` depuis `Épingler` va vers `Favori`. +- `Gauche` revient dans l'ordre inverse. +- `Gauche` depuis `Replay` ferme les actions et remet le focus sur la chaîne. +- `Haut` / `Bas` depuis une action ferment les actions et remettent le focus sur la chaîne. +- Le relâchement du premier OK long reste consommé, pour éviter le clic Replay automatique. + +## Fichier modifié + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` diff --git a/FOCUS_FIX_V6_NOTES.md b/FOCUS_FIX_V6_NOTES.md new file mode 100644 index 0000000..9fd8ab3 --- /dev/null +++ b/FOCUS_FIX_V6_NOTES.md @@ -0,0 +1,28 @@ +# Focus Fix V6 — Navigation actions chaîne + +## Objectif +Corriger l'accès aux actions de chaîne depuis la télécommande Android TV. + +## Problème V5 +Les actions `Replay`, `Épingler` et `Favori` étaient trois éléments focusables séparés. Sur certaines box Android TV, le focus Compose quittait la rangée d'actions au lieu de passer au bouton suivant. + +Symptômes constatés : +- depuis `Replay`, flèche droite ne menait pas à `Épingler` ; +- flèche gauche pouvait partir vers `Paramètres` ; +- impossible d'atteindre `Favori`. + +## Correction V6 +La rangée d'actions est maintenant un seul bloc focusable avec une sélection interne : + +- `OK long` sur une chaîne : ouvre le mode actions ; +- `Droite` depuis la carte : entre dans les actions, sur `Replay` ; +- `Droite` dans les actions : Replay → Épingler → Favori ; +- `Gauche` dans les actions : Favori → Épingler → Replay ; +- `Gauche` depuis Replay : ferme les actions et revient à la chaîne ; +- `Haut` / `Bas` : ferme les actions et revient à la chaîne ; +- `OK` : exécute l'action sélectionnée. + +Cette méthode évite de laisser Compose choisir le prochain focus entre trois petits boutons. Le focus reste dans le bloc d'actions jusqu'à fermeture volontaire. + +## Fichier modifié +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` diff --git a/FOCUS_FIX_V7_NOTES.md b/FOCUS_FIX_V7_NOTES.md new file mode 100644 index 0000000..20137f3 --- /dev/null +++ b/FOCUS_FIX_V7_NOTES.md @@ -0,0 +1,20 @@ +# Focus Fix V7 + +Correction ciblée dans `HomeScreen.kt`. + +## Problème corrigé + +Dans la V6, le parent de la carte chaîne interceptait toujours la touche `DirectionRight` quand le mode actions était ouvert. + +Conséquence : même quand le focus était visuellement sur `Replay`, l’événement droite était consommé par la carte parent avant d’arriver au bloc actions. La sélection restait donc bloquée sur Replay. + +## Correction + +Le parent ne consomme maintenant `DirectionRight` que si le bloc actions n’a pas encore le focus. + +Une fois dans les actions : + +- Droite : Replay → Épingler → Favori +- Gauche : Favori → Épingler → Replay +- Gauche depuis Replay : retour à la chaîne +- OK : exécute l’action sélectionnée diff --git a/FOCUS_FIX_V8_NOTES.md b/FOCUS_FIX_V8_NOTES.md new file mode 100644 index 0000000..00a1834 --- /dev/null +++ b/FOCUS_FIX_V8_NOTES.md @@ -0,0 +1,16 @@ +# Correctif V8 - Replay qui lançait le Live + +## Problème +Quand le focus était sur le bloc d'actions et que l'action sélectionnée était `Replay`, un appui sur OK lançait la chaîne Live au lieu d'ouvrir le guide Replay. + +## Cause +Le parent de la carte chaîne interceptait encore les événements OK / Enter en `onPreviewKeyEvent` avant le bloc d'actions. Comme le parent associe OK court à `onPlay()`, le clic sur Replay était transformé en lecture Live. + +## Correction +Quand le bloc d'actions a le focus, le parent ne traite plus OK / Enter et laisse le bloc d'actions exécuter l'action sélectionnée : + +- Replay => `onReplay()` +- Épingler => `onTogglePin()` +- Favori => `onToggleFav()` + +La navigation gauche / droite du bloc d'actions reste inchangée. diff --git a/MENU_HOME_V9_NOTES.md b/MENU_HOME_V9_NOTES.md new file mode 100644 index 0000000..67ee3cb --- /dev/null +++ b/MENU_HOME_V9_NOTES.md @@ -0,0 +1,81 @@ +# HERMES focus_fix_v9_menu_home + +Base utilisée : `12-IPTV_Android_TV_HERMES_focus_fix_v8.zip`. + +## Objectif + +Ajouter un écran d'accueil principal avec trois entrées : + +- TV +- VOD +- SÉRIES + +Le bouton Paramètres est placé dans le coin supérieur droit du nouvel accueil. + +## Changements principaux + +### Navigation + +- Ajout de `NavState.MainMenu`. +- L'application démarre maintenant sur le menu principal après connexion. +- Depuis TV, VOD et SÉRIES, le bouton BACK revient au menu principal. +- Depuis le menu principal, BACK affiche une confirmation de sortie. + +### Écran TV + +- Les boutons `Movies` et `Series` ont été retirés du header TV. +- La recherche reste accessible depuis l'écran TV. +- Le bouton Paramètres reste disponible aussi dans TV par sécurité, mais le point d'accès principal est maintenant sur le menu d'accueil. +- La logique de focus/actions V8 a été conservée. + +### Nouvel écran + +Nouveau fichier : + +```text +app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt +``` + +Fichiers modifiés : + +```text +app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt +app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt +app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt +``` + +## Commandes de build + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" + +$env:JAVA_HOME="D:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" + +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +## Installation Mi Box + +```powershell +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb devices +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` + +## Test conseillé + +1. Lancer l'app : vérifier que le menu principal apparaît. +2. OK sur TV : ouvre la liste des chaînes. +3. BACK depuis TV : revient au menu. +4. OK sur VOD : ouvre les films. +5. BACK depuis VOD : revient au menu. +6. OK sur SÉRIES : ouvre les séries. +7. BACK depuis SÉRIES : revient au menu. +8. Dans TV, vérifier que les boutons `Movies` et `Series` ne sont plus présents. +9. Vérifier que la navigation focus V8 sur les chaînes fonctionne toujours : OK long, Droite, Replay, Pin, Favori. diff --git a/README_INSTALLATION_YOUTUBE_MINIPLAYER.md b/README_INSTALLATION_YOUTUBE_MINIPLAYER.md new file mode 100644 index 0000000..cbe8ec9 --- /dev/null +++ b/README_INSTALLATION_YOUTUBE_MINIPLAYER.md @@ -0,0 +1,66 @@ +# Patch HERMES Android TV — Mini lecteur YouTube discret + +Ce ZIP contient uniquement les fichiers ajoutés/modifiés pour ajouter un mini lecteur YouTube visible au-dessus du lecteur TV existant. + +## Fichiers ajoutés/modifiés + +### Version principale du projet +- `12-IPTV Andoid TV HERMES/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt` +- `12-IPTV Andoid TV HERMES/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt` + +### Copie miroir incluse dans ton ZIP d'origine +Le ZIP d'origine contenait aussi une copie interne du projet dans `app/app/`. Pour éviter le grand cirque Android Studio, les mêmes fichiers sont aussi fournis ici : +- `12-IPTV Andoid TV HERMES/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt` +- `12-IPTV Andoid TV HERMES/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt` + +Si ton projet est ouvert depuis le dossier racine `12-IPTV Andoid TV HERMES`, la version importante est `app/src/...`. +Si ton projet est ouvert depuis `12-IPTV Andoid TV HERMES/app`, la version importante peut être `app/app/src/...`. + +## Fonctionnement + +Dans l'écran de lecture TV : + +- `▲` ouvre le mini lecteur YouTube. +- La TV reste affichée mais son volume passe à `0`. +- Colle un lien YouTube dans le champ puis clique sur `Charger`. +- `OK` met YouTube en pause/lecture. +- `◀` recule YouTube de 10 secondes. +- `▶` avance YouTube de 10 secondes. +- `Retour` ferme le mini lecteur YouTube et remet le son TV. + +## Types de liens supportés + +Support propre avec commandes JavaScript : +- `https://www.youtube.com/watch?v=VIDEO_ID` +- `https://youtu.be/VIDEO_ID` +- `https://www.youtube.com/live/VIDEO_ID` +- `https://www.youtube.com/shorts/VIDEO_ID` +- liens avec playlist `list=...` + +Les liens de chaîne type `@chaine/live` ou certaines pages YouTube peuvent s'ouvrir dans le WebView, mais les commandes avance/recul peuvent être limitées. YouTube adore compliquer ce qui devrait être simple, visiblement. + +## Notes techniques + +- Aucun changement Gradle ajouté. +- Aucun changement Manifest ajouté : la permission `INTERNET` existe déjà. +- Le mini lecteur utilise `WebView` + YouTube IFrame API. +- Le lecteur TV existant reste basé sur ExoPlayer / Media3. +- Le mini lecteur reste visible pour éviter le mode audio YouTube caché, qui peut poser problème avec les règles YouTube. + +## Vérification + +J'ai tenté une compilation Gradle dans l'environnement disponible, mais le wrapper Gradle voulait télécharger `gradle-9.1.0-bin.zip` et l'environnement n'avait pas accès à `services.gradle.org`. + +Commande tentée : + +```bash +./gradlew :app:compileDebugKotlin --no-daemon --stacktrace +``` + +Erreur rencontrée : + +```text +java.net.UnknownHostException: services.gradle.org +``` + +Le patch a donc été préparé avec vérification statique des chemins et du code, mais il doit être compilé/testé dans ton environnement Android Studio ou sur ta machine Windows où Gradle fonctionne déjà. Comme toujours, la compilation Android choisit le moment parfait pour réclamer Internet, parce que la civilisation était trop calme. diff --git a/SPECIFICATIONS.md b/SPECIFICATIONS.md new file mode 100644 index 0000000..71f0c4d --- /dev/null +++ b/SPECIFICATIONS.md @@ -0,0 +1,486 @@ +# iMedia TV+ / Xtream Google TV Player (Hermes Edition) +## Spécifications, architecture et guide du projet + +**Projet :** Lecteur IPTV Xtream pour Google TV / Android TV +**Package Android :** `ma.tkgraph.xtreamtv` +**Plateforme cible :** Google TV / Android TV +**Langage :** Kotlin +**Interface :** Jetpack Compose / Compose TV +**Lecteur vidéo :** AndroidX Media3 ExoPlayer +**API IPTV :** Xtream Codes compatible +**Date du document :** 11/06/2026 +**Version :** 2.0 (Hermes) + +--- + +## 1. Objectif du projet + +Développer une application Google TV complète pour lire des contenus IPTV depuis un serveur Xtream, en mettant l'accent sur : + +- **Qualité logicielle** : tests, architecture propre, sécurité +- **Expérience utilisateur** : navigation fluide, télécommande, réactivité +- **Résilience** : gestion d'erreur, cache, fallbacks gracieux +- **Maintenabilité** : documentation, historique, conventions + +Fonctionnalités couvertes : +- Connexion à un serveur Xtream + mémorisation sécurisée des identifiants +- Lecture Live TV avec EPG court +- Guide TV global + Replay / Catch-up TV +- VOD et Séries avec catalogues complets +- Favoris persistants (Live, VOD, Séries) +- Chaîne épinglée au démarrage +- Navigation complète à la télécommande Google TV +- Support multilingue (français, anglais, arabe) +- Cache local EPG pour performances + +--- + +## 2. Architecture + +### 2.1. Diagramme de flux + +```mermaid +flowchart TB + subgraph UI ["Couche UI (Compose TV)"] + Login[LoginScreen] + Home[HomeScreen] + Player[PlayerScreen] + Guide[ProgramGuideScreen] + Replay[ReplayGuideScreen] + Vod[VodScreen] + Series[SeriesScreen] + end + + subgraph ViewModel ["Couche ViewModel"] + VM_Home[HomeViewModel] + VM_Player[PlayerViewModel] + VM_Vod[VodViewModel] + VM_Series[SeriesViewModel] + end + + subgraph Repository ["Couche Repository"] + Repo_Auth[AuthRepository] + Repo_Live[LiveRepository] + Repo_Vod[VodRepository] + Repo_Series[SeriesRepository] + Repo_Epg[EpgRepository] + end + + subgraph Data ["Couche Data"] + API[XtreamApi
Retrofit] + Cache[EpgCache
Room / SQLite] + Storage[ConfigStorage
EncryptedSharedPreferences] + end + + subgraph External ["Externe"] + Xtream[Xtream Server] + end + + Login --> VM_Home + Home --> VM_Home + Player --> VM_Player + Guide --> VM_Player + Replay --> VM_Player + Vod --> VM_Vod + Series --> VM_Series + + VM_Home --> Repo_Auth + VM_Home --> Repo_Live + VM_Home --> Repo_Epg + VM_Player --> Repo_Live + VM_Player --> Repo_Epg + VM_Vod --> Repo_Vod + VM_Series --> Repo_Series + + Repo_Auth --> Storage + Repo_Live --> API + Repo_Vod --> API + Repo_Series --> API + Repo_Epg --> API + Repo_Epg --> Cache + + API --> Xtream + Storage -.->|Fallback| API +``` + +### 2.2. Flux de navigation + +```mermaid +flowchart LR + Splash[Splash
iMedia TV+] --> Login[LoginScreen] + Login --> Home[HomeScreen] + + Home -->|Live TV| LiveList[Liste Chaînes] + Home -->|Guide TV| GuideScreen[Guide TV Global] + Home -->|VOD| VodScreen[Catalogue VOD] + Home -->|Séries| SeriesScreen[Catalogue Séries] + + LiveList --> Player[PlayerScreen] + GuideScreen --> Player + ReplayGuide --> Player + VodScreen --> Player + SeriesScreen --> Player + + Player -->|Retour 1| Overlay + Player -->|Retour 2| PreviousScreen + + Home -->|Retour| ConfirmQuit{Confirmer?} +``` + +### 2.3. Structure des dossiers + +``` +app/ + └── src/ + ├── main/ + │ ├── AndroidManifest.xml + │ ├── java/ma/tkgraph/xtreamtv/ + │ │ ├── MainActivity.kt + │ │ ├── data/ + │ │ │ ├── model/ ← DTOs Xtream + │ │ │ ├── network/ ← Retrofit API + interceptors + │ │ │ ├── repository/ ← Live, VOD, Series, EPG, Auth + │ │ │ └── storage/ ← EncryptedSharedPreferences + │ │ ├── ui/ + │ │ │ ├── components/ ← ChannelLogo, CategoryRow, etc. + │ │ │ ├── screens/ ← Login, Home, Player, Guide, etc. + │ │ │ └── theme/ ← Thème Material 3 + │ │ └── util/ ← UrlBuilder, CategorySort, Extensions + │ └── res/ + │ ├── drawable/ ← launch_background.xml + │ ├── drawable-nodpi/ ← iMedia TV+ splash PNG + │ ├── mipmap-*/ ← icône app + │ ├── values/ ← strings.xml (default = EN) + │ ├── values-fr/ ← strings.xml (français) + │ └── values-ar/ ← strings.xml (العربية) + ├── test/ ← Tests unitaires (JUnit + MockK) + └── androidTest/ ← Tests UI (Compose Test) +``` + +--- + +## 3. Stack technique détaillée + +| Couche | Technologie | Justification | +|--------|-------------|---------------| +| Langage | Kotlin 2.x | Moderne, sûr, natif Android | +| UI | Jetpack Compose + Compose TV (1.7+) | Déclaratif, natif TV | +| Lecteur | AndroidX Media3 ExoPlayer 1.5+ | Standard Google, HLS/DASH/MP4 | +| Réseau | Retrofit 2.11+ / OkHttp 4.12+ | Robuste, interceptable | +| Images | Coil 3.x | Asynchrone, cache, fallback | +| Cache EPG | Room 2.7+ (SQLite) | Performant, requêtes typées | +| Stockage | EncryptedSharedPreferences + AES-256 | Sécurité des credentials | +| Tests unitaires | JUnit 5 / MockK / Turbine | Kotlin-friendly, Flow test | +| Tests UI | Compose UI Test + Espresso | Automatisation UI TV | +| Logging | Timber | Structuré, tags, crash | +| Crash reporting | Firebase Crashlytics | Monitoring production | + +--- + +## 4. Environnement de développement + +| Composant | Valeur | +|-----------|--------| +| OS de développement | Windows 11 | +| IDE | Android Studio (Ladybug+) | +| Terminal | PowerShell | +| Appareil cible | Google TV / Android TV 12+ (API 31+) | +| Connexion | ADB Wi-Fi ou USB | +| JDK | OpenJDK 21 (via Android Studio) | +| SDK Android | API 36, Build-Tools 36.x | +| Gradle | 9.1+ (wrapper) | + +### 4.1. Variables d'environnement + +```powershell +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:Path = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;D:\Tools\Gradle\gradle-9.1.0\bin;$env:Path" +``` + +### 4.2. Compilation + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK générée : `app\build\outputs\apk\debug\app-debug.apk` + +--- + +## 5. Sécurité + +### 5.1. Stockage des identifiants + +**Problème de la v1 :** identifiants stockés en clair dans `SharedPreferences`. + +**Solution Hermes :** `EncryptedSharedPreferences` avec AES-256 GCM. + +```kotlin +// data/storage/SecureStorage.kt +val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + +val prefs = EncryptedSharedPreferences.create( + context, + "imedia_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM +) +``` + +### 5.2. Règles de sécurité + +- Aucun log contenant `username`, `password` ou `server_url` +- Timeout réseau configuré (15s connect, 30s read) +- Validation côté client avant envoi : URL bien formée, champs non vides +- Pas de stockage en cache HTTP des credentials +- OkHttp interceptor pour masquer les auth dans les logs debug +- Préparation au HTTPS (certificats, SSR) + +--- + +## 6. Gestion d'erreurs + +### 6.1. Stratégie générale + +| Scénario | Comportement | Message utilisateur | +|----------|-------------|---------------------| +| Réseau indisponible | Toast + fallback cache EPG | "Connexion impossible. Vérifiez votre réseau." | +| Timeout API (15s) | Retry 1x, puis message | "Le serveur ne répond pas. Réessayez." | +| Auth échouée | Effacer champs, focus sur login | "Identifiants incorrects ou serveur invalide." | +| Flux vidéo mort | 503 overlay, retour menu | "Flux indisponible pour cette chaîne." | +| EPG vide | Afficher "Aucun programme" | "Aucun programme trouvé pour cette chaîne." | +| Données corrompues | Fallback valeurs par défaut | "Des données sont manquantes — certains affichages peuvent être incomplets." | + +### 6.2. État UI avec sealed class + +```kotlin +sealed class UiState { + object Idle : UiState() + object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String, val retry: Boolean = true) : UiState() +} +``` + +### 6.3. Intercepteur réseau + +```kotlin +class ErrorInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return try { + val response = chain.proceed(request) + if (!response.isSuccessful) { + throw XtreamHttpException(response.code, response.message) + } + response + } catch (e: SocketTimeoutException) { + throw XtreamTimeoutException("Le serveur Xtream est injoignable") + } catch (e: UnknownHostException) { + throw XtreamNetworkException("Aucune connexion réseau") + } + } +} +``` + +--- + +## 7. Cache EPG + +### 7.1. Problème + +Le guide TV global peut nécessiter des centaines d'appels API (1 par chaîne). Sans cache, l'écran est inutilisable. + +### 7.2. Solution : Room (SQLite) + +```kotlin +@Entity(tableName = "epg_cache") +data class EpgCacheEntry( + @PrimaryKey val id: Long, + val streamId: Int, + val channelName: String, + val title: String, + val description: String?, + val startTimestamp: Long, + val endTimestamp: Long, + val cachedAt: Long = System.currentTimeMillis() +) +``` + +### 7.3. Politique de cache + +| Type | Durée | Invalidation | +|------|-------|-------------| +| EPG court (live) | 2 minutes | Rafraîchi au scroll | +| EPG complet (guide) | 15 minutes | Pull-to-refresh manuel | +| Catégories | 1 heure | Au lancement | +| Listes chaînes | 5 minutes | Changement de catégorie | + +### 7.4. Flux EPG + +```mermaid +flowchart TD + A[Ouvre Guide TV] --> B{Cache valide?} + B -->|Oui < 15min| C[Afficher depuis Room] + B -->|Non| D[Requête API par chaîne] + D --> E[Stocker dans Room] + E --> C +``` + +--- + +## 8. Tests + +### 8.1. Tests unitaires (JUnit 5 + MockK) + +| Fichier | Test | Ce qu'on vérifie | +|---------|------|------------------| +| `AuthRepositoryTest.kt` | Connexion valide/invalide | Repository retourne `Success` ou `Error` | +| `AuthRepositoryTest.kt` | Timeout réseau | Erreur lisible, pas de crash | +| `EpgRepositoryTest.kt` | Cache hit/miss | Appel API si cache expiré, sinon données locales | +| `CategorySortUtilsTest.kt` | Tri FR/AR/autres | Ordre exact des catégories | +| `XtreamUrlBuilderTest.kt` | URLs Live/VOD/Series | Format correct de chaque URL | +| `SecureStorageTest.kt` | Lecture/écriture chiffrée | Données diffférentes en clair vs stocké | +| `PlayerViewModelTest.kt` | Zapping gauche/droite | Chaîne suivante/précédente correcte | +| `FavoritesRepositoryTest.kt` | Ajout/suppression favoris | Persistance après redémarrage | + +### 8.2. Tests UI (Compose UI Test) + +| Fichier | Test | +|---------|------| +| `LoginScreenTest.kt` | Remplissage formulaire → bouton actif | +| `LoginScreenTest.kt` | Erreur → message d'erreur affiché | +| `HomeScreenTest.kt` | Navigation entre catégories OK | +| `HomeScreenTest.kt` | Favori accessible via télécommande | +| `PlayerScreenTest.kt` | Overlay disparaît après 3s | + +### 8.3. Lancer les tests + +```powershell +# Tests unitaires uniquement +.\gradlew.bat test --no-daemon + +# Tests UI (appareil/émulateur connecté) +.\gradlew.bat connectedAndroidTest --no-daemon + +# Les deux +.\gradlew.bat test connectedAndroidTest --no-daemon +``` + +--- + +## 9. Internationalisation (i18n) + +### 9.1. Langues supportées + +| Langue | Dossier | Statut | +|--------|---------|--------| +| Anglais (défaut) | `values/` | ✅ Prévu | +| Français | `values-fr/` | ✅ Prévu | +| Arabe | `values-ar/` | ✅ Prévu (RTL) | + +### 9.2. Exemple + +```xml + +iMedia TV+ +Live TV +Connect +Connection lost. Check your network. + + +iMedia TV+ +Direct TV +Connexion +Connexion perdue. Vérifiez votre réseau. + + +iMedia TV+ +البث المباشر +اتصال +انقطع الاتصال. تحقق من شبكتك. +``` + +--- + +## 10. Logging et Monitoring + +### 10.1. Timber + +```kotlin +// Application.kt +class XtreamApplication : Application() { + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(CrashReportingTree()) + } + } +} +``` + +Règles : +- `Timber.d` / `Timber.i` pour debug et infos +- `Timber.w` pour anomalies non bloquantes +- `Timber.e` pour erreurs utilisateur +- **Jamais** de log de credentials, tokens, ou données sensibles + +### 10.2. Crash reporting + +Firebase Crashlytics est prévu pour la version release : +- Rapports de crash automatiques +- Logs non-sensibles attachés aux rapports +- Clés custom : écran actif, dernier état réseau + +--- + +## 11. Roadmap + +### Phase 1 — Fondations (immédiat) +- [x] Structure du projet +- [x] Stack technique définie +- [ ] Connexion Xtream + SecureStorage +- [ ] Live TV avec Media3 + +### Phase 2 — Fonctionnalités cœur +- [ ] Catégories + Favoris Live +- [ ] EPG court + Replay +- [ ] Guide TV global +- [ ] VOD + Séries + +### Phase 3 — Qualité +- [ ] Tests unitaires (couverture > 70%) +- [ ] Cache EPG (Room) +- [ ] Gestion d'erreur complète +- [ ] i18n (FR, EN, AR) + +### Phase 4 — Production +- [ ] Signing + ProGuard +- [ ] Firebase Crashlytics +- [ ] Bannière Android TV + icônes finales +- [ ] Politique de confidentialité +- [ ] Publication Play Store + +--- + +## 12. Conclusion + +Cette version Hermes reprend l'excellente base de la v1 et y ajoute : + +1. **🔐 Sécurité** : chiffrement AES-256 des credentials +2. **📦 Cache EPG** : Room SQLite pour des performances réelles +3. **🧪 Tests** : stratégie complète (unitaire + UI) +4. **🌍 i18n** : support FR/EN/AR +5. **📋 Gestion d'erreur** : sealed class + intercepteurs + messages lisibles +6. **📊 Monitoring** : Timber + Crashlytics +7. **📐 Architecture** : flux documentés en Mermaid + +Le logiciel fonctionnait déjà en v1. Cette version le rend fiable, sécurisé et prêt pour la production. diff --git a/Site web/README.md b/Site web/README.md new file mode 100644 index 0000000..800f82d --- /dev/null +++ b/Site web/README.md @@ -0,0 +1,39 @@ +# Site web iMedia TV+ + +Site vitrine statique pour vendre l'application iMedia TV+. + +## Fichiers +- `index.html` +- `styles.css` +- `script.js` +- `assets/logo.svg` + +## À modifier avant publication +1. Remplacer `contact@votre-domaine.com` dans `index.html`. +2. Remplacer les textes `Sur devis` par tes prix. +3. Ajouter ton WhatsApp, ton nom commercial et tes mentions légales. + +## Publication +Envoie le dossier sur Netlify, Vercel, GitHub Pages ou un hébergement classique. + + +## Payhip - Starter + +Le bouton Starter pointe vers : + +`https://payhip.com/buy?link=REMPLACER_CLE_PAYHIP` + +À remplacer par ta vraie clé produit Payhip. + +Exemple : +- URL produit Payhip : `https://payhip.com/b/ean3D` +- Clé produit : `ean3D` +- Lien checkout direct : `https://payhip.com/buy?link=ean3D` + +Dans `index.html`, cherche : + +`REMPLACER_CLE_PAYHIP` + +et remplace par ta clé. + +Le prix affiché actuellement est `49 €`. Modifie-le dans `index.html` si tu veux un autre prix. diff --git a/Site web/assets/logo.svg b/Site web/assets/logo.svg new file mode 100644 index 0000000..a6e52bc --- /dev/null +++ b/Site web/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Site web/index.html b/Site web/index.html new file mode 100644 index 0000000..bc8ef48 --- /dev/null +++ b/Site web/index.html @@ -0,0 +1,189 @@ + + + + + + iMedia TV+ | Application IPTV Android TV + + + + +
+ +
+ +
+
+
+
+
+

Android TV • Google TV • Mi Box • Xtream • M3U

+

Votre application IPTV professionnelle, rapide et prête à vendre.

+

iMedia TV+ transforme une Android TV en plateforme IPTV complète : chaînes LIVE, VOD, séries, replay, recherche globale, YouTube, historique intelligent et navigation télécommande.

+ +
+ Xtream CodesM3U / M3U Plus100% télécommande +
+
+ +
+
+
iMedia TV+
+
+
+
TVChaînes en direct
+
VODFilms
+
SÉRIESÉpisodes
+
SRCRecherche
+
YTYouTube
+
+

Dernières chaînes LIVE

+
SPORT HD
NEWS 24
MOVIES
+

Continuer à regarder

+
Série en coursÉpisode 04 • reprise instantanée
+
+
+

Interface pensée pour grands écrans, focus visible et télécommande.

+
+
+
+ +
+
Android TVGoogle TVMi BoxXtream APIM3U
+
+ +
+
+
+

Fonctionnalités

+

Tout ce qu’un lecteur IPTV moderne doit offrir.

+

Une application complète, claire et rapide, au lieu d’une interface bricolée qui donne envie de jeter la télécommande par la fenêtre.

+
+
+
TV

LIVE rapide

Chargement optimisé des chaînes, lecture plein écran, zapping et catégories.

+
VOD

Films à la demande

Catalogue VOD, affiches, lecture et reprise intelligente.

+
SER

Séries & épisodes

Gestion des saisons, épisodes et progression de visionnage.

+
SRC

Recherche globale

Recherche LIVE, VOD et séries sans bloquer l’interface.

+
↺

Continuer à regarder

Reprise des films et épisodes commencés avec barre de progression.

+
★

Tri par usage

Les catégories, chaînes et contenus les plus utilisés remontent automatiquement.

+
⏱

Replay / timeshift

Support du replay quand le fournisseur Xtream le permet.

+
YT

YouTube intégré

Ajout de vidéos YouTube par URL, miniatures et lecteur intégré.

+
🔒

Stockage sécurisé

Identifiants stockés localement avec chiffrement Android.

+
+
+
+ +
+
+
+

Expérience TV

+

Conçue pour la télécommande, pas pour martyriser l’utilisateur.

+

iMedia TV+ propose une navigation claire : menu principal, dernières chaînes LIVE, continuer à regarder, scroll vertical, focus visible et saisie adaptée aux Mi Box.

+
    +
  • Menu TV, VOD, Séries, Recherche et YouTube.
  • +
  • Dernières chaînes LIVE au-dessus de l’historique.
  • +
  • Reprise automatique des VOD et épisodes.
  • +
  • Saisie via fenêtre native compatible Android TV.
  • +
  • Navigation DPAD / télécommande optimisée.
  • +
+
+
+
2 secChargement LIVE constaté sur base optimisée
+
3Sources dans la recherche : LIVE, VOD, séries
+
100%Interface pensée Android TV
+
+
+
+ +
+
+
+

Compatible Xtream & M3U

+

Connexion par base serveur Xtream, identifiant et mot de passe, ou par playlist M3U distante.

+ http://serveur.com:8080 + https://exemple.com/playlist.m3u +
+
+

Version white label

+

Nom, logo, couleurs, écran d’accueil et options peuvent être adaptés pour revendeurs, hôtels, résidences, intégrateurs ou projets média.

+ Créer ma version +
+
+
+ +
+
+
+

Offres

+

Des formules simples à vendre.

+

Remplace les prix par tes vrais tarifs. Oui, il faut choisir un prix, cet antique rituel commercial.

+
+
+
+

Starter

+ 49 € +

Application standard, Xtream/M3U, APK prêt à installer. Paiement sécurisé via Payhip.

+ + Acheter Starter +
+ +

Sur mesure

Projet

Fonctionnalités spécifiques, API privée, portail client, maintenance.

Discuter
+
+
+
+ +
+
+

FAQ

Questions fréquentes

+
L’application fournit-elle des chaînes ?

Non. iMedia TV+ est un lecteur. Le client utilise ses propres accès ou playlists autorisés.

+
Est-ce compatible Mi Box ?

Oui. L’interface a été conçue et testée pour Android TV, Google TV et Mi Box.

+
Peut-on personnaliser l’application ?

Oui : nom, logo, couleurs, écran d’accueil et options selon le projet.

+
Peut-on ajouter des fonctions ?

Oui : profils, panneau admin, paiement, EPG avancé, portail client, etc.

+
Comment se passe l’achat Starter ?

Le bouton Starter envoie directement vers le checkout Payhip. Après paiement, Payhip livre le fichier numérique que tu as attaché au produit : APK, ZIP, guide d’installation ou pack complet.

+
+
+ +
+
+
+

Contact

+

Demandez une démo ou une version personnalisée.

+

Remplace l’email du formulaire par ton vrai email, ajoute ton WhatsApp, et le site est prêt à publier.

+
+
+ + + + + +
+
+
+
+ +
+ +

© iMedia TV+. Tous droits réservés.

+
+ + + diff --git a/Site web/script.js b/Site web/script.js new file mode 100644 index 0000000..1cf9e58 --- /dev/null +++ b/Site web/script.js @@ -0,0 +1 @@ +const burger=document.getElementById('burger');const links=document.getElementById('links');if(burger&&links){burger.addEventListener('click',()=>links.classList.toggle('open'));links.querySelectorAll('a').forEach(a=>a.addEventListener('click',()=>links.classList.remove('open')))}const y=document.getElementById('year');if(y)y.textContent=new Date().getFullYear(); \ No newline at end of file diff --git a/Site web/styles.css b/Site web/styles.css new file mode 100644 index 0000000..16eaf4b --- /dev/null +++ b/Site web/styles.css @@ -0,0 +1 @@ +:root{--bg:#08090d;--bg2:#10131b;--panel:rgba(255,255,255,.07);--stroke:rgba(255,255,255,.13);--text:#fff;--muted:#a9afc0;--orange:#ff7a18;--red:#e50914;--green:#42d77d;--blue:#4f8cff;--r:26px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}a{text-decoration:none;color:inherit}.container{width:min(1160px,calc(100% - 40px));margin:auto}.narrow{width:min(850px,calc(100% - 40px))}.header{position:sticky;top:0;z-index:10;background:rgba(8,9,13,.75);backdrop-filter:blur(18px);border-bottom:1px solid var(--stroke)}.nav{min-height:74px;display:flex;align-items:center;justify-content:space-between;width:min(1160px,calc(100% - 40px));margin:auto}.brand{display:flex;align-items:center;gap:12px;font-weight:950;font-size:1.25rem;letter-spacing:-.04em}.brand img{width:42px}.links{display:flex;gap:24px;align-items:center;color:var(--muted);font-weight:800}.links a:hover{color:#fff}.cta-small,.btn.primary{background:linear-gradient(135deg,var(--orange),var(--red));color:#fff!important;box-shadow:0 18px 40px rgba(229,9,20,.26)}.cta-small{padding:10px 16px;border-radius:999px}.burger{display:none;background:var(--panel);border:1px solid var(--stroke);border-radius:12px;width:44px;height:44px}.burger span{display:block;width:20px;height:2px;background:#fff;margin:5px auto}.hero{position:relative;overflow:hidden;padding:96px 0 80px}.glow{position:absolute;inset:-200px -100px auto;height:650px;background:radial-gradient(circle at 20% 20%,rgba(255,122,24,.35),transparent 35%),radial-gradient(circle at 80% 10%,rgba(79,140,255,.25),transparent 35%),radial-gradient(circle at 70% 70%,rgba(229,9,20,.18),transparent 42%);filter:blur(8px)}.hero-grid{position:relative;display:grid;grid-template-columns:1fr .9fr;gap:58px;align-items:center}.eyebrow{color:#ffbd70;text-transform:uppercase;letter-spacing:.16em;font-weight:950;font-size:.78rem}h1,h2,h3{line-height:1.05;letter-spacing:-.055em;margin:0}h1{font-size:clamp(2.8rem,7vw,5.8rem)}h2{font-size:clamp(2rem,4.5vw,3.6rem)}.lead,.section-title p,.card p,.split p,footer p{color:var(--muted);font-size:1.06rem}.actions{display:flex;gap:14px;flex-wrap:wrap;margin:34px 0}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 22px;border-radius:999px;font-weight:950;border:1px solid transparent}.btn.secondary{background:rgba(255,255,255,.07);border-color:var(--stroke)}.badges{display:flex;gap:12px;flex-wrap:wrap}.badges span{padding:12px 16px;background:rgba(255,255,255,.055);border:1px solid var(--stroke);border-radius:999px;color:var(--muted);font-weight:850}.screen-card,.card,.features article,.pricing article,.stats,details,form{background:var(--panel);border:1px solid var(--stroke);border-radius:var(--r);box-shadow:0 30px 90px rgba(0,0,0,.35)}.screen-card{padding:20px}.screen{background:#0b0d12;border:1px solid rgba(255,255,255,.15);border-radius:24px;overflow:hidden}.screen-bar{height:46px;background:#151923;display:flex;gap:8px;align-items:center;padding:0 16px}.screen-bar span{width:10px;height:10px;border-radius:99px}.screen-bar span:nth-child(1){background:var(--red)}.screen-bar span:nth-child(2){background:var(--orange)}.screen-bar span:nth-child(3){background:var(--green)}.screen-bar b{margin-left:auto;color:var(--muted);font-size:.8rem}.screen-body{padding:20px;min-height:360px;background:radial-gradient(circle at 20% 0%,rgba(255,122,24,.18),transparent 34%),linear-gradient(145deg,#090b10,#111827)}.tiles,.mini-row{display:flex;gap:10px}.tile{flex:1;min-height:86px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:14px;font-weight:950}.tile.active{border-color:var(--orange);background:linear-gradient(145deg,rgba(255,122,24,.34),rgba(255,255,255,.06))}.tile small{display:block;color:var(--muted);font-size:.68rem;margin-top:8px}.screen h4{margin:22px 0 10px}.mini-row div{width:31%;min-height:60px;display:grid;place-items:center;background:#161b27;border:1px solid rgba(255,255,255,.1);border-radius:16px;color:#ffbd70;font-weight:950}.continue{display:flex;gap:14px;align-items:center;padding:12px;border-radius:18px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1)}.continue i{width:58px;height:72px;border-radius:12px;background:linear-gradient(135deg,var(--orange),var(--red))}.continue small{display:block;color:var(--muted)}.continue em{display:block;width:210px;height:6px;background:rgba(255,255,255,.14);border-radius:99px;margin-top:12px;overflow:hidden}.continue em span{display:block;width:62%;height:100%;background:var(--orange)}.screen-card p{color:var(--muted);margin:14px 6px 0}.strip{border-block:1px solid var(--stroke);background:rgba(255,255,255,.035)}.strip-grid{min-height:86px;display:grid;grid-template-columns:repeat(5,1fr);align-items:center;text-align:center;gap:16px;color:var(--muted);font-weight:950}.section{padding:92px 0}.dark{background:linear-gradient(180deg,#0c0f16,#08090d)}.section-title{max-width:860px;margin-bottom:44px}.grid{display:grid;gap:18px}.features{grid-template-columns:repeat(3,1fr)}.features article,.pricing article{padding:26px}.features strong{width:54px;height:54px;display:grid;place-items:center;border-radius:18px;background:linear-gradient(135deg,rgba(255,122,24,.25),rgba(229,9,20,.16));color:#ffbd70;margin-bottom:18px}.features p,.pricing p,details p{color:var(--muted)}.split{display:grid;grid-template-columns:1fr .9fr;gap:34px;align-items:start}.checks{padding:0;list-style:none}.checks li{position:relative;padding-left:32px;margin:13px 0;color:var(--muted);font-weight:750}.checks li:before{content:'✓';position:absolute;left:0;color:var(--green);font-weight:950}.stats{padding:28px;display:grid;gap:18px}.stats div{padding:20px;border-radius:18px;background:rgba(255,255,255,.055)}.stats b{display:block;font-size:2.8rem;line-height:1;color:#ffbd70}.stats span{color:var(--muted)}.card{padding:32px}.highlight{background:radial-gradient(circle at 20% 0%,rgba(255,122,24,.2),transparent 35%),var(--panel)}code{display:block;padding:14px;border-radius:14px;background:rgba(0,0,0,.35);color:#d7dbe8;margin-top:10px;white-space:normal}.pricing{grid-template-columns:repeat(3,1fr)}.pricing article b{font-size:2rem}.popular{border-color:rgba(255,122,24,.65)!important;transform:translateY(-10px)}.popular small{float:right;padding:7px 10px;border-radius:99px;background:linear-gradient(135deg,var(--orange),var(--red));font-weight:950}details{padding:20px 24px;margin:12px 0}summary{font-weight:950;cursor:pointer}form{padding:26px;display:grid;gap:16px}label{display:grid;gap:7px;color:var(--muted);font-weight:850}input,select,textarea{width:100%;border:1px solid var(--stroke);background:rgba(0,0,0,.32);color:#fff;border-radius:14px;padding:13px 14px;font:inherit}footer{border-top:1px solid var(--stroke);background:#06070a;padding:46px 0 0}.footer-grid{display:grid;grid-template-columns:1.2fr .6fr 1fr;gap:40px}.footer-grid a{display:block;color:var(--muted);margin:8px 0}.copy{border-top:1px solid var(--stroke);margin:36px 0 0;padding:18px 0;text-align:center;color:#737b8f}@media(max-width:960px){.hero-grid,.split,.footer-grid{grid-template-columns:1fr}.features,.pricing{grid-template-columns:repeat(2,1fr)}.strip-grid{grid-template-columns:repeat(2,1fr);padding:20px 0}}@media(max-width:720px){.burger{display:block}.links{position:absolute;top:74px;left:20px;right:20px;display:none;flex-direction:column;align-items:stretch;padding:18px;border:1px solid var(--stroke);border-radius:20px;background:#0c0f16}.links.open{display:flex}.features,.pricing{grid-template-columns:1fr}.popular{transform:none}.section{padding:66px 0}.tiles{overflow:hidden}.tile{min-width:90px}.continue em{width:130px}} \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..e29a79f --- /dev/null +++ b/TESTING.md @@ -0,0 +1,130 @@ +# Stratégie de test — iMedia TV+ + +## Principes + +- **TDD partout où c'est possible** : écrire le test avant l'implémentation +- **Couverture cible** : > 70% sur les couches `data/` et `util/` +- **Tests UI** : parcours critiques uniquement (login, lecture, favoris) +- **Exécution locale** : tous les tests doivent passer avant chaque commit + +## Stack de test + +| Technologie | Usage | +|-------------|-------| +| JUnit 5 | Runner principal | +| MockK | Mocking Kotlin (classes, objects, extensions) | +| Turbine | Test des Flow (StateFlow, SharedFlow) | +| Kotlinx Coroutines Test | `runTest`, `TestDispatcher` | +| Compose UI Test | Test des écrans Compose TV | +| Espresso | Complément pour interactions système | + +## Tests unitaires + +### data/repository/ + +| Fichier | Scénarios | +|---------|-----------| +| `AuthRepositoryTest.kt` | ✅ Réponse valide → `auth == "1"` → `Success` | +| | ✅ Réponse valide → `auth == "0"` → `Error("Identifiants incorrects")` | +| | ✅ Timeout → `Error("Le serveur ne répond pas")` | +| | ✅ Socket error → `Error("Aucune connexion réseau")` | +| `LiveRepositoryTest.kt` | ✅ Catégories chargées → liste triée FR/AR/autres | +| | ✅ Chaînes filtrées par catégorie | +| | ✅ Flux vide → liste vide (pas de crash) | +| `EpgRepositoryTest.kt` | ✅ Cache valide (< 2min) → pas d'appel API | +| | ✅ Cache expiré → appel API + mise à jour cache | +| | ✅ Pas de cache → appel API + insertion | +| | ✅ EPG vide → `Error("Aucun programme")` | +| `FavoritesRepositoryTest.kt` | ✅ Ajout favori → liste contient l'ID | +| | ✅ Suppression → liste ne contient plus l'ID | +| | ✅ Persistance → données conservées après recreation du repo | +| `SeriesRepositoryTest.kt` | ✅ Détail série retourne saisons + épisodes | +| | ✅ Épisode sans titre → fallback "Épisode N" | + +### data/storage/ + +| Fichier | Scénarios | +|---------|-----------| +| `SecureStorageTest.kt` | ✅ Valeur écrite = valeur lue | +| | ✅ Données différentes après clair vs chiffré | +| | ✅ Clear supprime toutes les données | +| | ✅ Valeur inexistante → null safe | + +### util/ + +| Fichier | Scénarios | +|---------|-----------| +| `CategorySortUtilsTest.kt` | ✅ Catégories FR en premier | +| | ✅ Catégories AR ensuite | +| | ✅ Catégories "Toutes" et "Favoris" toujours en haut | +| | ✅ Autres en dernier | +| | ✅ Ordre alphabétique à l'intérieur des groupes | +| `XtreamUrlBuilderTest.kt` | ✅ URL Live correcte avec m3u8 | +| | ✅ URL VOD avec extension dynamique | +| | ✅ URL série avec épisode_id | +| | ✅ URL Replay avec timestamp formaté | + +## Tests d'intégration / UI + +| Fichier | Parcours | +|---------|----------| +| `LoginScreenTest.kt` | ✅ Remplit les champs → bouton Connexion activé | +| | ✅ Champs vides → bouton désactivé | +| | ✅ Erreur réseau → message d'erreur visible | +| | ✅ Connexion OK → navigation vers HomeScreen | +| `HomeScreenTest.kt` | ✅ Catégories affichées au chargement | +| | ✅ Changement de catégorie → nouvelles chaînes | +| | ✅ Favori accessible avec télécommande (DPAD) | +| | ✅ Recherche par nom fonctionne | +| `PlayerScreenTest.kt` | ✅ Overlay visible → disparaît après 3s | +| | ✅ Zapping gauche → chaîne précédente | +| | ✅ Premier retour → overlay réapparaît | +| | ✅ Deuxième retour → retour à HomeScreen | + +## Exécution + +```powershell +# Tout +.\gradlew.bat test connectedAndroidTest --no-daemon + +# Tests unitaires uniquement +.\gradlew.bat test --no-daemon + +# Tests UI +.\gradlew.bat connectedAndroidTest --no-daemon + +# Filtre par classe +.\gradlew.bat test --no-daemon --tests *AuthRepositoryTest* +.\gradlew.bat connectedAndroidTest --no-daemon --tests *LoginScreenTest* + +# Rapport de couverture +.\gradlew.bat :app:createDebugCoverageReport --no-daemon +# HTML → app/build/reports/coverage/debug/index.html +``` + +## Convention d'écriture + +```kotlin +class AuthRepositoryTest { + private val api = mockk() + private val storage = mockk() + private val repo = AuthRepository(api, storage) + + @Test + fun `login with valid credentials returns Success`() = runTest { + // Given + coEvery { api.authenticate(any(), any(), any()) } returns AuthResponse( + userInfo = UserInfo(username = "test"), + auth = "1", + status = "Active" + ) + + // When + val result = repo.login("http://server", "user", "pass") + + // Then + assertTrue(result is UiState.Success) + assertEquals("test", (result as UiState.Success).data.username) + } +} +``` diff --git a/TV_OK.m3u b/TV_OK.m3u new file mode 100644 index 0000000..311dbec --- /dev/null +++ b/TV_OK.m3u @@ -0,0 +1,1471 @@ +#EXTM3U x-tvg-url="https://epgshare01.online/epgshare01/epg_ripper_AL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ALJAZEERA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ALL_SOURCES1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_AR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ASIANTELEVISION1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_AT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_AU1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_BA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_BEIN1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_BG1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_BR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CO1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CY1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_CZ1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DELUXEMUSIC1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DIRECTVSPORTS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DISTROTV1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DK1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DO1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DRAFTKINGS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_DUMMY_CHANNELS.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_EC1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_EG1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ES1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_FANDUEL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_FI1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_GR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_HK1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_HR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_HU1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ID1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_IE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_IL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_IN1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_IN4.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_IT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_JM1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_JP1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_JP2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_KE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_KR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_LT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_LV1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_MT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_MX1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_MY1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_NG1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_NL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_NO1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_NZ1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PAC-12.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PH1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PH2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PK1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PLEX1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_POWERNATION1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_PT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_DE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_EN1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_ES1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_FR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_IT1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_NL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RAKUTEN_PL1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RALLY_TV1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RO1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RO2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_RS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SA2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SE1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SG1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SK1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SPORTKLUB1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SSPORTPLUS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_SV1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_TBNPLUS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_THESPORTPLUS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_TR1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_TR3.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_UK1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_US1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_US_LOCALS2.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_US_SPORTS1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_UY1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_VN1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_VOA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_ZA1.xml.gz, https://epgshare01.online/epgshare01/epg_ripper_viva-russia.ru.xml.gz, https://epgshare01.online/epgshare01/locomotiontv.xml.gz" +#EXTINF:-1 tvg-name="AlbKanale Music TV Ⓢ" tvg-logo="https://i.imgur.com/JdKxscs.png" tvg-id="AlbKanaleMusicTV.al" tvg-country="AL" group-title="Albania",AlbKanale Music TV Ⓢ +https://albportal.net/albkanalemusic.m3u8 +#EXTINF:-1 tvg-name="CNA" tvg-logo="https://i.imgur.com/X3ukD5t.png" tvg-id="CNA.al" tvg-country="AL" group-title="Albania",CNA +https://live1.mediadesk.al/cnatvlive.m3u8 +#EXTINF:-1 tvg-name="News 24 Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/2/2d/News_24_%28Albania%29.svg/1024px-News_24_%28Albania%29.svg.png" tvg-id="News24.al" tvg-country="AL" group-title="Albania",News 24 Ⓢ +https://tv.balkanweb.com/news24/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Ora News" tvg-logo="https://i.imgur.com/ILZY5bJ.png" tvg-id="OraNews.al" tvg-country="AL" group-title="Albania",Ora News +https://live1.mediadesk.al/oranews.m3u8 +#EXTINF:-1 tvg-name="Tropoja" tvg-logo="https://i.imgur.com/D3hNOVS.png" tvg-id="TropojaTelevizion.al" tvg-country="AL" group-title="Albania",Tropoja +https://live.prostream.al/al/smil:tropojatv.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Canal E" tvg-logo="https://i.ibb.co/y4pkxH3/Qtc8-M2-PG-400x400.jpg" tvg-id="CanalE.ar" tvg-chno="25.6" tvg-country="AR" group-title="Argentina",Canal E +https://unlimited1-us.dps.live/perfiltv/perfiltv.smil/perfiltv/livestream2/chunks.m3u8 +#EXTINF:-1 tvg-name="Net TV" tvg-logo="https://i.imgur.com/EWmshtx.png" tvg-id="NETTV.ar" tvg-chno="27.2" tvg-country="AR" group-title="Argentina",Net TV +https://unlimited1-us.dps.live/nettv/nettv.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="TV Universidad" tvg-logo="https://i.imgur.com/tvLHiAT.png" tvg-id="TVUniversidad.ar" tvg-chno="32.1" tvg-country="AR" group-title="Argentina",TV Universidad +https://stratus.stream.cespi.unlp.edu.ar/hls/tvunlp.m3u8 +#EXTINF:-1 tvg-name="W24" tvg-logo="https://i.imgur.com/PGb4wYw.png" tvg-id="W24.at" tvg-chno="7" tvg-country="AT" group-title="Austria",W24 +https://ms01.w24.at/W24/smil:liveevent.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTV" tvg-logo="https://i.imgur.com/oD7GQxT.png" tvg-id="RTV.at" tvg-chno="9" tvg-country="AT" group-title="Austria",RTV +http://iptv.rtv-ooe.at/stream.m3u8 +#EXTINF:-1 tvg-name="R9" tvg-logo="https://i.imgur.com/2fxVYsL.jpg" tvg-id="R9.at" tvg-chno="12" tvg-country="AT" group-title="Austria",R9 +https://ms01.w24.at/R9/smil:liveeventR9.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="AzStarTV" tvg-logo="https://i.imgur.com/di3XX5L.png" tvg-id="AzStarTV.ca" tvg-country="AZ" group-title="Azerbaijan",AzStarTV +http://live.azstartv.com/azstar/smil:azstar.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Беларусь 3" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Belarus_3_logo.svg/742px-Belarus_3_logo.svg.png" tvg-id="Belarus3.by" tvg-country="BY" group-title="Belarus",Беларусь 3 +https://ngtrk.dc.beltelecom.by/ngtrk/smil:belarus3.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Беларусь 5" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Belarus_5_logo.svg/742px-Belarus_5_logo.svg.png" tvg-id="Belarus5.by" tvg-country="BY" group-title="Belarus",Беларусь 5 +https://ngtrk.dc.beltelecom.by/ngtrk/smil:belarus5.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="СТВ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/%D0%9B%D0%BE%D0%B3%D0%BE%D1%82%D0%B8%D0%BF_%D0%B1%D0%B5%D0%BB%D0%BE%D1%80%D1%83%D1%81%D1%81%D0%BA%D0%BE%D0%B3%D0%BE_%D1%82%D0%B5%D0%BB%D0%B5%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB%D0%B0_%C2%AB%D0%A1%D0%A2%D0%92%C2%BB.svg/640px-%D0%9B%D0%BE%D0%B3%D0%BE%D1%82%D0%B8%D0%BF_%D0%B1%D0%B5%D0%BB%D0%BE%D1%80%D1%83%D1%81%D1%81%D0%BA%D0%BE%D0%B3%D0%BE_%D1%82%D0%B5%D0%BB%D0%B5%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB%D0%B0_%C2%AB%D0%A1%D0%A2%D0%92%C2%BB.svg.png" tvg-id="STV.by" tvg-chno="5" tvg-country="BY" group-title="Belarus",СТВ +https://ctv.dc.beltelecom.by/ctv/ctv.stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Беларусь 24" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Belarus_24_logo.svg/742px-Belarus_24_logo.svg.png" tvg-id="Belarus24.by" tvg-country="BY" group-title="Belarus",Беларусь 24 +https://ngtrk.dc.beltelecom.by/ngtrk/smil:belarus24.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="1Mus" tvg-logo="https://i.imgur.com/PozF9MT.png" tvg-id="FirstMusicChannel.by" tvg-chno="1" tvg-country="BY" group-title="Belarus",1Mus +http://hz1.teleport.cc/HLS/HD.m3u8 +#EXTINF:-1 tvg-name="Belros Ⓢ" tvg-logo="https://i.imgur.com/HWqxjGl.png" tvg-id="BelRos.ru" tvg-chno="3" tvg-country="BY" group-title="Belarus",Belros Ⓢ +https://live2.mediacdn.ru/sr1/tro/playlist.m3u8 +#EXTINF:-1 tvg-name="BX1" tvg-logo="https://i.imgur.com/YjKqWru.png" tvg-id="BX1.be" tvg-chno="13" tvg-country="BE" group-title="Belgium",BX1 +https://59959724487e3.streamlock.net/stream/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Televizija Republike Srpske (RTRS)" tvg-logo="https://i.imgur.com/2Sgs5gM.png" tvg-id="RTRSTV.ba" tvg-chno="3" tvg-country="BA" group-title="Bosnia and Herzegovina",Televizija Republike Srpske (RTRS) +https://parh.rtrs.tv/tv/live/playlist.m3u8 +#EXTINF:-1 tvg-name="RTV HB Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/en/6/60/Logo_of_TV_Herceg-Bosne.png" tvg-id="RTVHB.ba" tvg-chno="5" tvg-country="BA" group-title="Bosnia and Herzegovina",RTV HB Ⓢ +https://prd-hometv-live-open.spectar.tv/ERO_1_083/playlist.m3u8 +#EXTINF:-1 tvg-name="RTV BN" tvg-logo="https://i.imgur.com/DUBvfWb.png" tvg-id="BNTV.ba" tvg-chno="6" tvg-country="BA" group-title="Bosnia and Herzegovina",RTV BN +https://stream2.rtvbn.tv:8080/live_1935/bnstream/index.m3u8 +#EXTINF:-1 tvg-name="Televizija 5" tvg-logo="https://i.imgur.com/znpvJys.png" tvg-id="Televizija5.ba" tvg-chno="10" tvg-country="BA" group-title="Bosnia and Herzegovina",Televizija 5 +https://balkanmedia.dynu.net/hls/tv5web.m3u8 +#EXTINF:-1 tvg-name="RTV ZE Ⓢ" tvg-logo="https://i.imgur.com/TKUaflB.png" tvg-id="RTVZenica.ba" tvg-chno="4" tvg-country="BA" group-title="Bosnia and Herzegovina",RTV ZE Ⓢ +https://stream.rtvze.ba/live/123/123.m3u8 +#EXTINF:-1 tvg-name="ISTV" tvg-logo="https://upload.wikimedia.org/wikipedia/pt/b/b5/Logotipo_da_ISTV.png" tvg-id="ISTVHD.br" tvg-chno="8" tvg-country="BR" group-title="Brazil",ISTV +https://video08.logicahost.com.br/istvnacional/srt.stream/istvnacional.m3u8 +#EXTINF:-1 tvg-name="Rede Brasil" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/d/d1/Marca_rede_brasil_rgb-color.png" tvg-id="RedeBrasil.br" tvg-chno="9" tvg-country="BR" group-title="Brazil",Rede Brasil +https://video09.logicahost.com.br/redebrasiloficial/redebrasiloficial/playlist.m3u8 +#EXTINF:-1 tvg-name="TV Câmara" tvg-logo="https://i.imgur.com/UpV2PRk.png" tvg-id="TVCamara.br" tvg-chno="10" tvg-country="BR" group-title="Brazil",TV Câmara +https://stream3.camara.gov.br/tv1/manifest.m3u8 +#EXTINF:-1 tvg-name="TV Cultura" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/8/82/Cultura_logo_2013.svg" tvg-id="TVCultura.br" tvg-chno="12" tvg-country="BR" group-title="Brazil",TV Cultura +https://player-tvcultura.stream.uol.com.br/live/tvcultura.m3u8 +#EXTINF:-1 tvg-name="City TV Ⓢ" tvg-logo="https://i.imgur.com/BjRTbrU.png" tvg-id="City.bg" tvg-chno="8" tvg-country="BG" group-title="Bulgaria",City TV Ⓢ +https://tv.city.bg/play/tshls/citytv/index.m3u8 +#EXTINF:-1 tvg-name="TV1" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/6/64/Tv1-new.png" tvg-id="TV1.bg" tvg-country="BG" group-title="Bulgaria",TV1 +https://tv1.cloudcdn.bg/temp/livestream.m3u8 +#EXTINF:-1 tvg-name="ICI RDI" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/ICI_RDI_logo.svg/640px-ICI_RDI_logo.svg.png" tvg-id="IciRDI.ca" tvg-chno="1" tvg-country="CA" group-title="Canada",ICI RDI +https://rcavlive.akamaized.net/hls/live/704025/xcanrdi/master.m3u8 +#EXTINF:-1 tvg-name="ICI Télé HD Ⓖ" tvg-logo="https://i.imgur.com/HsSi3NV.png" tvg-chno="2" tvg-country="CA" group-title="Canada",ICI Télé HD Ⓖ +https://rcavlive.akamaized.net/hls/live/696615/xcancbft/master.m3u8 +#EXTINF:-1 tvg-name="Savoir Média" tvg-logo="https://i.imgur.com/pa4wOVY.png" tvg-id="CFTUDT.ca" tvg-chno="6" tvg-country="CA" group-title="Canada",Savoir Média +https://hls.savoir.media/live/stream.m3u8 +#EXTINF:-1 tvg-name="ICI Montreal" tvg-logo="https://i.imgur.com/Z1b2TJD.png" tvg-id="CBFTDT.ca" tvg-chno="3" tvg-country="CA" group-title="Canada",ICI Montreal +https://amdici.akamaized.net/hls/live/873426/ICI-Live-Stream/master.m3u8 +#EXTINF:-1 tvg-name="Toronto 360 TV" tvg-logo="https://i.imgur.com/PkWndsv.png" tvg-id="Toronto360.tv" tvg-chno="4" tvg-country="CA" group-title="Canada",Toronto 360 TV +http://cdn3.toronto360.tv:8081/toronto360/hd/playlist.m3u8 +#EXTINF:-1 tvg-name="UCV Televisión" tvg-logo="https://i.imgur.com/2VL4Pts.png" tvg-id="UCVTV.cl" tvg-chno="1" tvg-country="CL" group-title="Chile",UCV Televisión +https://unlimited1-cl-isp.dps.live/ucvtv2/ucvtv2.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="T13 en vivo" tvg-logo="https://i.imgur.com/3CEijac.png" tvg-id="T13.cl" tvg-chno="10" tvg-country="CL" group-title="Chile",T13 en vivo +https://redirector.rudo.video/hls-video/10b92cafdf3646cbc1e727f3dc76863621a327fd/t13/t13.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="13 Cultura" tvg-logo="https://i.imgur.com/49QkKWv.png" tvg-id="13C.cl" tvg-chno="12" tvg-country="CL" group-title="Chile",13 Cultura +https://origin.dpsgo.com/ssai/event/GI-9cp_bT8KcerLpZwkuhw/master.m3u8 +#EXTINF:-1 tvg-name="13 Kids" tvg-logo="https://i.imgur.com/m6y9AMe.png" tvg-id="13Kids.cl" tvg-chno="14" tvg-country="CL" group-title="Chile",13 Kids +https://origin.dpsgo.com/ssai/event/LhHrVtyeQkKZ-Ye_xEU75g/master.m3u8 +#EXTINF:-1 tvg-name="13 Realities" tvg-logo="https://i.imgur.com/p1Qpljw.png" tvg-id="13Realities.cl" tvg-chno="15" tvg-country="CL" group-title="Chile",13 Realities +https://origin.dpsgo.com/ssai/event/g7_JOM0ORki9SR5RKHe-Kw/master.m3u8 +#EXTINF:-1 tvg-name="13 Teleseries" tvg-logo="https://i.imgur.com/aJMBnse.png" tvg-id="13T.cl" tvg-chno="16" tvg-country="CL" group-title="Chile",13 Teleseries +https://origin.dpsgo.com/ssai/event/f4TrySe8SoiGF8Lu3EIq1g/master.m3u8 +#EXTINF:-1 tvg-name="El Pingüino TV" tvg-logo="https://i.imgur.com/ohXs2NV.png" tvg-id="ElPinguinoTV.cl" tvg-chno="17" tvg-country="CL" group-title="Chile",El Pingüino TV +https://redirector.rudo.video/hls-video/339f69c6122f6d8f4574732c235f09b7683e31a5/pinguinotv/pinguinotv.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="UCL" tvg-logo="https://i.imgur.com/JxqVHPX.png" tvg-id="UCL.uy" tvg-chno="18" tvg-country="CL" group-title="Chile",UCL +https://redirector.rudo.video/hls-video/c54ac2799874375c81c1672abb700870537c5223/ucl/ucl.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Deportes13 Ⓖ" tvg-logo="https://i.imgur.com/GRpxoPf.png" tvg-id="D13.cl" tvg-chno="19" tvg-country="CL" group-title="Chile",Deportes13 Ⓖ +https://redirector.rudo.video/hls-video/ey6283je82983je9823je8jowowiekldk9838274/13d/13d.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="TVN 3" tvg-logo="https://i.imgur.com/84lWqRi.png" tvg-id="TVN3.cl" tvg-chno="20" tvg-country="CL" group-title="Chile",TVN 3 +https://mdstrm.com/live-stream-playlist/5653641561b4eba30a7e4929.m3u8 +#EXTINF:-1 tvg-name="SoloTV" tvg-logo="https://i.imgur.com/Ad2ueID.png" tvg-id="SoloTV.cl" tvg-chno="22" tvg-country="CL" group-title="Chile",SoloTV +https://stream.making.cl/memfs/ee909361-73e9-4e74-8391-5f1b0b8006c2.m3u8 +#EXTINF:-1 tvg-name="CCTV-4 中文国际(美) Ⓢ" tvg-logo="https://i.imgur.com/1TPiRqR.png" tvg-id="CCTV4America.cn" tvg-chno="6" tvg-country="CN" group-title="China",CCTV-4 中文国际(美) Ⓢ +https://global.cgtn.cicc.media.caton.cloud/master/cgtn-america.m3u8 +#EXTINF:-1 tvg-name="Canal 8" tvg-logo="https://platform-static.cdn.mdstrm.com/player/logo/5efe501c21d05a0722152f6d.png" tvg-id="Canal8.cr" tvg-chno="8" tvg-country="CR" group-title="Costa Rica",Canal 8 +http://mdstrm.com/live-stream-playlist/5a7b1e63a8da282c34d65445.m3u8 +#EXTINF:-1 tvg-name="CMC TV" tvg-logo="https://i.imgur.com/Fh2vawT.png" tvg-id="CMCTV.hr" tvg-chno="13" tvg-country="HR" group-title="Croatia",CMC TV +https://stream.cmctv.hr:49998/cmc/live.m3u8 +#EXTINF:-1 tvg-name="Televizija Slavonije i Baranje (STV)" tvg-logo="https://upload.wikimedia.org/wikipedia/hr/0/04/STV.PNG" tvg-id="STV.hr" tvg-chno="17" tvg-country="HR" group-title="Croatia",Televizija Slavonije i Baranje (STV) +http://89.201.163.244:8080/hls/hdmi.m3u8 +#EXTINF:-1 tvg-name="TV Nova" tvg-logo="https://upload.wikimedia.org/wikipedia/hr/c/c8/TVnova-logo.png" tvg-id="TVNova.hr" tvg-chno="38" tvg-country="HR" group-title="Croatia",TV Nova +https://stream.agatin.hr:3727/live/tvnovalive.m3u8 +#EXTINF:-1 tvg-name="TV Jadran" tvg-logo="https://upload.wikimedia.org/wikipedia/hr/9/9a/Tv_jadran_logo.png" tvg-id="TVJadran.hr" tvg-chno="51" tvg-country="HR" group-title="Croatia",TV Jadran +https://tvjadran.stream.agatin.hr:3412/live/tvjadranlive.m3u8 +#EXTINF:-1 tvg-name="Libertas TV" tvg-logo="https://upload.wikimedia.org/wikipedia/hr/8/8a/LibertasTV.png" tvg-id="LibertasTV.hr" tvg-chno="57" tvg-country="HR" group-title="Croatia",Libertas TV +https://stream.luci.xyz/hls/LTV.m3u8 +#EXTINF:-1 tvg-name="RIK Sat" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/ad/Logo_RIK_Sat_2017.svg/640px-Logo_RIK_Sat_2017.svg.png" tvg-id="RIKSat.cy" tvg-chno="4" tvg-country="CY" group-title="Cyprus",RIK Sat +https://l3.cloudskep.com/cybcsat/abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Óčko Ⓢ" tvg-logo="https://i.imgur.com/iPmpsnN.png" tvg-id="Ocko.cz" tvg-chno="21" tvg-country="CZ" group-title="Czech Republic",Óčko Ⓢ +https://ocko-live.ssl.cdn.cra.cz/channels/ocko/playlist.m3u8 +#EXTINF:-1 tvg-name="Óčko Star Ⓢ" tvg-logo="https://i.imgur.com/tGzQFWw.png" tvg-id="OckoStar.cz" tvg-chno="22" tvg-country="CZ" group-title="Czech Republic",Óčko Star Ⓢ +https://ocko-live.ssl.cdn.cra.cz/channels/ocko_gold/playlist.m3u8 +#EXTINF:-1 tvg-name="Óčko Expres Ⓢ" tvg-logo="https://i.imgur.com/e731JNS.png" tvg-id="OckoExpres.cz" tvg-chno="23" tvg-country="CZ" group-title="Czech Republic",Óčko Expres Ⓢ +https://ocko-live.ssl.cdn.cra.cz/channels/ocko_expres/playlist.m3u8 +#EXTINF:-1 tvg-name="Retro Music Television Ⓢ" tvg-logo="https://i.imgur.com/a6S2Yo4.png" tvg-id="RetroMusicTV.cz" tvg-chno="24" tvg-country="CZ" group-title="Czech Republic",Retro Music Television Ⓢ +https://stream.mediawork.cz/retrotv/smil:retrotv2.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Východoceská TV" tvg-logo="https://i.imgur.com/4Wwptd3.png" tvg-id="V1.cz" tvg-chno="28" tvg-country="CZ" group-title="Czech Republic",Východoceská TV +https://stream.polar.cz/vctv/vctvlive-1/playlist.m3u8 +#EXTINF:-1 tvg-name="UTV (Czech Republic)" tvg-logo="https://imgur.com/ulfeIwM.png" tvg-id="utv.cz" tvg-chno="29" tvg-country="CZ" group-title="Czech Republic",UTV (Czech Republic) +https://vysilani.zaktv.cz/broadcast/hls/utv/index.m3u8 +#EXTINF:-1 tvg-name="Folketinget TV" tvg-logo="https://i.imgur.com/RqQDUzX.png" tvg-id="TVfromtheDanishParliament.dk" tvg-chno="37" tvg-country="DK" group-title="Denmark",Folketinget TV +https://cdnapi.kaltura.com/p/2158211/sp/327418300/playManifest/entryId/1_24gfa7qq/protocol/https/format/applehttp/a.m3u8 +#EXTINF:-1 tvg-name="Kanal Hovedstaden" tvg-logo="https://i.imgur.com/MCXYDwH.png" tvg-id="KanalHovedstaden.dk" tvg-chno="2" tvg-country="DK" group-title="Denmark",Kanal Hovedstaden +http://khkbh.dk:8080/hls/livestream/index.m3u8 +#EXTINF:-1 tvg-name="Canal RTVD 4" tvg-logo="https://static.wikia.nocookie.net/logopedia/images/4/4e/CERTV_4_2015.png" tvg-id="Canal4RD.do" tvg-chno="4" tvg-country="DO" group-title="Dominican Republic",Canal RTVD 4 +https://protvradiostream.com:1936/canal4rd-1/ngrp:canal4rd-1_all/playlist.m3u8 +#EXTINF:-1 tvg-name="Aghapy TV" tvg-logo="https://upload.wikimedia.org/wikipedia/en/e/eb/AghapyTV.jpg" tvg-id="AghapyTV.eg" tvg-chno="1" tvg-country="EG" group-title="Egypt",Aghapy TV +https://5b622f07944df.streamlock.net/aghapy.tv/aghapy.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Al Ghad TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/0/06/AlGhad_TV.png" tvg-id="AlGhadTV.eg" tvg-chno="3" tvg-country="EG" group-title="Egypt",Al Ghad TV +https://eazyvwqssi.erbvr.com/alghadtv/alghadtv.m3u8 +#EXTINF:-1 tvg-name="Alhayat TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/8/8c/Al-Hayat_Media_Center_Logo_%28variant_2%29.svg" tvg-id="AlhayatTV.eg" tvg-chno="5" tvg-country="EG" group-title="Egypt",Alhayat TV +https://cdn3.wowza.com/5/OE5HREpIcEkySlNT/alhayat-live/ngrp:livestream_all/playlist.m3u8 +#EXTINF:-1 tvg-name="Huda TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/5/58/Logo_huda_%D8%AD%D8%AC%D9%85_%D9%83%D8%A8%D9%8A%D8%B1.gif" tvg-id="HudaTV.eg" tvg-chno="7" tvg-country="EG" group-title="Egypt",Huda TV +https://cdn.bestream.io:19360/elfaro1/elfaro1.m3u8 +#EXTINF:-1 tvg-name="Koogi TV" tvg-logo="" tvg-id="KoogiTV.eg" tvg-chno="8" tvg-country="EG" group-title="Egypt",Koogi TV +https://5d658d7e9f562.streamlock.net/koogi.tv/koogi.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Watan TV" tvg-logo="" tvg-id="WatanTV.eg" tvg-chno="12" tvg-country="EG" group-title="Egypt",Watan TV +https://rp.tactivemedia.com/watantv_source/live/playlist.m3u8 +#EXTINF:-1 tvg-name="ETV Ⓖ" tvg-logo="https://i.imgur.com/5URjPgG.png" tvg-id="ETV.ee" tvg-chno="1" tvg-country="EE" group-title="Estonia",ETV Ⓖ +http://sb.err.ee/live/etv.m3u8 +#EXTINF:-1 tvg-name="ETV2 Ⓖ" tvg-logo="https://i.imgur.com/fUjGHDa.png" tvg-id="ETV2.ee" tvg-chno="2" tvg-country="EE" group-title="Estonia",ETV2 Ⓖ +http://sb.err.ee/live/etv2.m3u8 +#EXTINF:-1 tvg-name="ETV+ Ⓖ" tvg-logo="https://i.imgur.com/YAubPlU.png" tvg-id="ETVPlus.ee" tvg-chno="3" tvg-country="EE" group-title="Estonia",ETV+ Ⓖ +http://sb.err.ee/live/etvpluss.m3u8 +#EXTINF:-1 tvg-name="Taevas TV7" tvg-logo="https://i.imgur.com/usXedIj.png" tvg-id="TaevasTV7.fi" tvg-chno="5" tvg-country="EE" group-title="Estonia",Taevas TV7 +https://vod.tv7.fi/tv7-ee/_definst_/smil:tv7-ee.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Life TV Estonia" tvg-logo="https://i.imgur.com/JhrTB82.png" tvg-id="LifeTV.ee" tvg-chno="6" tvg-country="EE" group-title="Estonia",Life TV Estonia +https://lifetv.bitflip.ee/live/stream2.m3u8 +#EXTINF:-1 tvg-name="Life TV Europe" tvg-logo="https://i.imgur.com/JhrTB82.png" tvg-id="LifeTVEurope.ee" tvg-chno="7" tvg-country="EE" group-title="Estonia",Life TV Europe +https://lifetv.bitflip.ee/live/stream1.m3u8 +#EXTINF:-1 tvg-name="TBN Baltia" tvg-logo="https://i.imgur.com/rKBaK56.png" tvg-id="TBNBaltia.ee" tvg-chno="8" tvg-country="EE" group-title="Estonia",TBN Baltia +http://dc.tbnbaltia.eu:8088/dvr/rewind-21600.m3u8 +#EXTINF:-1 tvg-name="MTV3" tvg-logo="https://i.imgur.com/kNbmc8n.png" tvg-id="MTV3.fi" tvg-chno="3" tvg-country="FI" group-title="Finland",MTV3 +https://live-fi.tvkaista.net/mtv3/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Nelonen" tvg-logo="https://i.imgur.com/BFbCyfY.png" tvg-id="Nelonen.fi" tvg-chno="4" tvg-country="FI" group-title="Finland",Nelonen +https://live-fi.tvkaista.net/nelonen/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="MTV Sub" tvg-logo="https://i.imgur.com/VRCuxQt.png" tvg-id="Sub.fi" tvg-chno="6" tvg-country="FI" group-title="Finland",MTV Sub +https://live-fi.tvkaista.net/sub/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="TV5 Finland" tvg-logo="https://i.imgur.com/MoukyGs.png" tvg-id="TV5.fi" tvg-chno="7" tvg-country="FI" group-title="Finland",TV5 Finland +https://live-fi.tvkaista.net/tv5/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Liv" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/0/06/Liv_color_RGB.png" tvg-id="Liv.fi" tvg-chno="8" tvg-country="FI" group-title="Finland",Liv +https://live-fi.tvkaista.net/liv/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Jim" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/9/92/Jim_color_RGB.png" tvg-id="Jim.fi" tvg-chno="9" tvg-country="FI" group-title="Finland",Jim +https://live-fi.tvkaista.net/jim/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Kutonen" tvg-logo="https://i.imgur.com/4giVyxb.png" tvg-id="Kutonen.fi" tvg-chno="10" tvg-country="FI" group-title="Finland",Kutonen +https://live-fi.tvkaista.net/kutonen/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="TLC Finland" tvg-logo="https://i.imgur.com/0d5hP3A.png" tvg-id="TLCFinland.fi" tvg-chno="11" tvg-country="FI" group-title="Finland",TLC Finland +https://live-fi.tvkaista.net/tlc/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Star Channel Finland" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Star_Channel_2020.svg/640px-Star_Channel_2020.svg.png" tvg-id="StarChannel.fi" tvg-chno="12" tvg-country="FI" group-title="Finland",Star Channel Finland +https://live-fi.tvkaista.net/star-channel/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="MTV Ava" tvg-logo="https://i.imgur.com/rtyJVgB.png" tvg-id="AVA.fi" tvg-chno="13" tvg-country="FI" group-title="Finland",MTV Ava +https://live-fi.tvkaista.net/ava/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Hero" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/b/bd/Hero_color_RGB.png" tvg-id="Hero.fi" tvg-chno="14" tvg-country="FI" group-title="Finland",Hero +https://live-fi.tvkaista.net/hero/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Frii" tvg-logo="https://i.imgur.com/ljKoG9I.png" tvg-id="Frii.fi" tvg-chno="15" tvg-country="FI" group-title="Finland",Frii +https://live-fi.tvkaista.net/frii/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="Alfa Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/fi/9/93/IRR-TV-1.png" tvg-id="IRRTV.fi" tvg-chno="17" tvg-country="FI" group-title="Finland",Alfa Ⓢ +https://irrtv2.digitacdn.net/live/ott/irrtv/playlist.m3u8?organizationId=229401409&suiteItemId=230439940 +#EXTINF:-1 tvg-name="TapahtumaTV Eveo" tvg-logo="https://i.imgur.com/sR8nA8w.png" tvg-id="Eveo.fi" tvg-chno="18" tvg-country="FI" group-title="Finland",TapahtumaTV Eveo +https://live-fi.tvkaista.net/tapahtumatv-eveo/live.m3u8?src=freetv +#EXTINF:-1 tvg-name="OnniTV" tvg-logo="https://i.imgur.com/HzILf2H.png" tvg-id="KotiTV.fi" tvg-chno="33" tvg-country="FI" group-title="Finland",OnniTV +https://onnitv.digitacdn.net/live/ott/onnitv/playlist.m3u8?organizationId=83459409&suiteItemId=83459780 +#EXTINF:-1 tvg-name="Taivas TV7" tvg-logo="https://i.imgur.com/a4iNVXA.png" tvg-id="TaivasTV7.fi" tvg-chno="45" tvg-country="FI" group-title="Finland",Taivas TV7 +https://vod.tv7.fi/tv7-fi/_definst_/smil:tv7-fi.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Himlen TV7" tvg-logo="https://i.imgur.com/a4iNVXA.png" tvg-id="HimlenTV7.fi" tvg-chno="46" tvg-country="FI" group-title="Finland",Himlen TV7 +https://vod.tv7.fi/tv7-se/_definst_/smil:tv7-se.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="MTV Uutiset Live" tvg-logo="https://i.imgur.com/IyB6mIb.png" tvg-id="MTVUutiset.fi" tvg-chno="99" tvg-country="FI" group-title="Finland",MTV Uutiset Live +https://live.streaming.a2d.tv/asset/20025962.isml/.m3u8 +#EXTINF:-1 tvg-name="Nopola News" tvg-logo="https://i.imgur.com/gOj8J6O.png" tvg-id="NopolaNews.fi" tvg-country="FI" group-title="Finland",Nopola News +https://virta2.nopolanews.fi:8443/live/smil:Stream1.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="När-TV Ⓢ" tvg-logo="https://i.imgur.com/Ht5yePq.png" tvg-id="NarTV.fi" tvg-country="FI" group-title="Finland",När-TV Ⓢ +https://streaming.nartv.fi/live/ngrp:NAR_TV.stream_all/playlist.m3u8 +#EXTINF:-1 tvg-name="Järviradio TV" tvg-logo="https://jarviradio.fi/jrtv2/wp-content/uploads/2022/01/jrtv1.jpg" tvg-id="JRTVJarviradio.fi" tvg-country="FI" group-title="Finland",Järviradio TV +https://streamer.radiotaajuus.fi/memfs/47f113bf-04ea-493b-a9d4-52945fd9db31.m3u8 +#EXTINF:-1 tvg-name="TV5 Monde Info" tvg-logo="https://i.imgur.com/NcysrWH.png" tvg-id="TV5MondeInfo.fr" tvg-chno="29" tvg-country="FR" group-title="France",TV5 Monde Info +https://ott.tv5monde.com/Content/HLS/Live/channel(info)/index.m3u8 +#EXTINF:-1 tvg-name="First Channel (1TV)" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Pirveli_Arkhi_Logo_2022.svg/512px-Pirveli_Arkhi_Logo_2022.svg.png" tvg-id="1TV.ge" tvg-chno="1" tvg-country="GE" group-title="Georgia",First Channel (1TV) +https://tv.cdn.xsg.ge/gpb-1tv/index.m3u8 +#EXTINF:-1 tvg-name="First Channel /Education/ (2TV)" tvg-logo="https://upload.wikimedia.org/wikipedia/ka/c/c9/2_Tv_Logo.jpg" tvg-id="2TV.ge" tvg-chno="2" tvg-country="GE" group-title="Georgia",First Channel /Education/ (2TV) +https://tv.cdn.xsg.ge/gpb-2tv/index.m3u8 +#EXTINF:-1 tvg-name="Imedi TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/2/2a/Imlogo_2020.png" tvg-id="ImediTV.ge" tvg-chno="3" tvg-country="GE" group-title="Georgia",Imedi TV +https://tv.cdn.xsg.ge/imedihd/index.m3u8 +#EXTINF:-1 tvg-name="Formula" tvg-logo="https://i.imgur.com/fsqBn8G.png" tvg-id="Formula.ge" tvg-chno="8" tvg-country="GE" group-title="Georgia",Formula +https://c4635.cdn.xsg.ge/c4635/TVFormula/index.m3u8 +#EXTINF:-1 tvg-name="Das Erste Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/c/ca/Das_Erste_2014.svg/640px-Das_Erste_2014.svg.png" tvg-id="DasErste.de" tvg-chno="1" tvg-country="DE" group-title="Germany",Das Erste Ⓖ +https://daserste-live.ard-mcdn.de/daserste/live/hls/de/master.m3u8 +#EXTINF:-1 tvg-name="ARD Alpha Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/ARD_alpha.svg/640px-ARD_alpha.svg.png" tvg-id="ARDalpha.de" tvg-chno="4" tvg-country="DE" group-title="Germany",ARD Alpha Ⓖ +https://mcdn.br.de/br/fs/ard_alpha/hls/de/master.m3u8 +#EXTINF:-1 tvg-name="DELUXE MUSIC" tvg-logo="https://i.imgur.com/E65GQN9.png" tvg-id="DeluxeMusic.de" tvg-chno="6" tvg-country="DE" group-title="Germany",DELUXE MUSIC +https://sdn-global-live-streaming-packager-cache.3qsdn.com/13456/13456_264_live.m3u8 +#EXTINF:-1 tvg-name="DELUXE MUSIC DANCE BY KONTOR" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/Deluxe_Dance_by_Kontor_Logo_2023.svg/666px-Deluxe_Dance_by_Kontor_Logo_2023.svg.png" tvg-id="DeluxeMusicDanceByKontor.de" tvg-chno="7" tvg-country="DE" group-title="Germany",DELUXE MUSIC DANCE BY KONTOR +https://sdn-global-live-streaming-packager-cache.3qsdn.com/64733/64733_264_live.m3u8 +#EXTINF:-1 tvg-name="DELUXE MUSIC RAP" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Deluxe_Rap_Logo_2023.svg/666px-Deluxe_Rap_Logo_2023.svg.png" tvg-id="DeLuxeMusicRap.de" tvg-chno="8" tvg-country="DE" group-title="Germany",DELUXE MUSIC RAP +https://sdn-global-live-streaming-packager-cache.3qsdn.com/65183/65183_264_live.m3u8 +#EXTINF:-1 tvg-name="KiKa Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Kika_2012.svg/640px-Kika_2012.svg.png" tvg-id="KIKA.de" tvg-chno="11" tvg-country="DE" group-title="Germany",KiKa Ⓖ +https://kikageohls.akamaized.net/hls/live/2022693/livetvkika_de/master.m3u8 +#EXTINF:-1 tvg-name="Tagesschau24" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Tagesschau24-2012.svg/640px-Tagesschau24-2012.svg.png" tvg-id="tagesschau24.de" tvg-chno="16" tvg-country="DE" group-title="Germany",Tagesschau24 +https://tagesschau.akamaized.net/hls/live/2020115/tagesschau/tagesschau_1/master.m3u8 +#EXTINF:-1 tvg-name="BR Nord Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Logo_BR_Fernsehen_2021.svg/768px-Logo_BR_Fernsehen_2021.svg.png" tvg-id="BRFernsehenNord.de" tvg-chno="1" tvg-country="DE" group-title="Germany",BR Nord Ⓖ +https://mcdn.br.de/br/fs/bfs_nord/hls/de/master.m3u8 +#EXTINF:-1 tvg-name="HR Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/HR-Fernsehen_Logo_2023.svg/640px-HR-Fernsehen_Logo_2023.svg.png" tvg-id="HRFernsehen.de" tvg-chno="3" tvg-country="DE" group-title="Germany",HR Ⓖ +https://hrhls.akamaized.net/hls/live/2024525/hrhls/master.m3u8 +#EXTINF:-1 tvg-name="NDR Hamburg Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Logo_NDR_Fernsehen_2017.svg/578px-Logo_NDR_Fernsehen_2017.svg.png" tvg-id="NDRFernsehenHamburg.de" tvg-chno="7" tvg-country="DE" group-title="Germany",NDR Hamburg Ⓖ +https://mcdn.ndr.de/ndr/hls/ndr_fs/ndr_hh/master.m3u8 +#EXTINF:-1 tvg-name="NDR Mecklenburg-Vorpommern Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Logo_NDR_Fernsehen_2017.svg/578px-Logo_NDR_Fernsehen_2017.svg.png" tvg-id="NDRFernsehenMecklenburgVorpommern.de" tvg-chno="8" tvg-country="DE" group-title="Germany",NDR Mecklenburg-Vorpommern Ⓖ +https://mcdn.ndr.de/ndr/hls/ndr_fs/ndr_mv/master.m3u8 +#EXTINF:-1 tvg-name="NDR Niedersachsen Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Logo_NDR_Fernsehen_2017.svg/578px-Logo_NDR_Fernsehen_2017.svg.png" tvg-id="NDRFernsehenNiedersachsen.de" tvg-chno="9" tvg-country="DE" group-title="Germany",NDR Niedersachsen Ⓖ +https://mcdn.ndr.de/ndr/hls/ndr_fs/ndr_nds/master.m3u8 +#EXTINF:-1 tvg-name="NDR Schleswig-Holstein Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Logo_NDR_Fernsehen_2017.svg/578px-Logo_NDR_Fernsehen_2017.svg.png" tvg-id="NDRFernsehenSchleswigHolstein.de" tvg-chno="10" tvg-country="DE" group-title="Germany",NDR Schleswig-Holstein Ⓖ +https://mcdn.ndr.de/ndr/hls/ndr_fs/ndr_sh/master.m3u8 +#EXTINF:-1 tvg-name="SR Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/SR_Fernsehen_Logo_2023.svg/538px-SR_Fernsehen_Logo_2023.svg.png" tvg-id="SRFernsehen.de" tvg-chno="14" tvg-country="DE" group-title="Germany",SR Ⓖ +https://srfs.akamaized.net/hls/live/689649/srfsgeo/index.m3u8 +#EXTINF:-1 tvg-name="WDR Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Wdr_fernsehen_logo_2016.svg/640px-Wdr_fernsehen_logo_2016.svg.png" tvg-id="WDR.de" tvg-chno="17" tvg-country="DE" group-title="Germany",WDR Ⓖ +https://wdrfs247.akamaized.net/hls/live/681509/wdr_msl4_fs247/index.m3u8 +#EXTINF:-1 tvg-name="NDR International" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Logo_NDR_Fernsehen_2017.svg/578px-Logo_NDR_Fernsehen_2017.svg.png" tvg-id="NDRFernsehenInternational.de" tvg-chno="1" tvg-country="DE" group-title="Germany",NDR International +https://ndrint.akamaized.net/hls/live/2020766/ndr_int/index.m3u8 +#EXTINF:-1 tvg-name="ERT 3 Ⓖ" tvg-logo="https://i.imgur.com/KyhzDRm.png" tvg-id="ERT3.gr" tvg-chno="3" tvg-country="GR" group-title="Greece",ERT 3 Ⓖ +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERT3/default/index.mpd +#EXTINF:-1 tvg-name="ERT Sports 1" tvg-logo="https://i.imgur.com/gebWmAB.png" tvg-id="ERTSports1.gr" tvg-chno="6" tvg-country="GR" group-title="Greece",ERT Sports 1 +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERTSports1/default/index.mpd +#EXTINF:-1 tvg-name="ERT Sports 2" tvg-logo="https://i.imgur.com/gebWmAB.png" tvg-id="ERTSports2.gr" tvg-chno="7" tvg-country="GR" group-title="Greece",ERT Sports 2 +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERTSports2/default/index.mpd +#EXTINF:-1 tvg-name="ERT Sports 3" tvg-logo="https://i.imgur.com/gebWmAB.png" tvg-id="ERTSports3.gr" tvg-chno="8" tvg-country="GR" group-title="Greece",ERT Sports 3 +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERTSports3/default/index.mpd +#EXTINF:-1 tvg-name="ERT Sports 4" tvg-logo="https://i.imgur.com/gebWmAB.png" tvg-id="ERTSports4.gr" tvg-chno="9" tvg-country="GR" group-title="Greece",ERT Sports 4 +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERTSports4/default/index.mpd +#EXTINF:-1 tvg-name="ERT Kids Ⓖ" tvg-logo="https://i.imgur.com/XkSR66q.png" tvg-id="ERTKids.gr" tvg-chno="10" tvg-country="GR" group-title="Greece",ERT Kids Ⓖ +https://ert-ucdn.broadpeak-aas.com/bpk-tv/ERTKids/default/index.mpd +#EXTINF:-1 tvg-name="Vouli TV" tvg-logo="https://i.imgur.com/1vqW7lc.png" tvg-id="VouliTV.gr" tvg-chno="11" tvg-country="GR" group-title="Greece",Vouli TV +https://diavlos-cache.cnt.grnet.gr/parltv/webtv-1b.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="RIK Sat" tvg-logo="https://i.imgur.com/9edlXHP.png" tvg-id="RikSatTV.cy" tvg-chno="12" tvg-country="GR" group-title="Greece",RIK Sat +https://l3.cloudskep.com/cybcsat/abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Mega News" tvg-logo="https://i.imgur.com/Z3k7iA0.png" tvg-id="MegaChannel.gr" tvg-chno="22" tvg-country="GR" group-title="Greece",Mega News +https://c98db5952cb54b358365984178fb898a.msvdn.net/live/S99841657/NU0xOarAMJ5X/playlist.m3u8 +#EXTINF:-1 tvg-name="Star" tvg-logo="https://i.imgur.com/Hp0stVQ.png" tvg-id="StarChannel.gr" tvg-chno="24" tvg-country="GR" group-title="Greece",Star +https://livestar.siliconweb.com/starvod/star4/star4.m3u8 +#EXTINF:-1 tvg-name="Star International" tvg-logo="https://i.imgur.com/Hp0stVQ.png" tvg-id="StarChannel.gr" tvg-chno="25" tvg-country="GR" group-title="Greece",Star International +https://livestar.siliconweb.com/starvod/star_int/star_int.m3u8 +#EXTINF:-1 tvg-name="AlphaTV" tvg-logo="https://i.imgur.com/bAVGX0l.png" tvg-id="AlphaTV.gr" tvg-chno="26" tvg-country="GR" group-title="Greece",AlphaTV +https://alphatvlive2.siliconweb.com/alphatvlive/live_abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Skai TV" tvg-logo="https://i.imgur.com/TSg7B8X.png" tvg-id="SkaiTV.gr" tvg-chno="27" tvg-country="GR" group-title="Greece",Skai TV +http://skai-live.siliconweb.com/media/cambria4/index.m3u8 +#EXTINF:-1 tvg-name="Action24" tvg-logo="https://i.imgur.com/Zi1YohT.png" tvg-id="Action24TV.gr" tvg-chno="31" tvg-country="GR" group-title="Greece",Action24 +https://actionlive.siliconweb.com/actionabr/actiontv/playlist.m3u8 +#EXTINF:-1 tvg-name="Alert" tvg-logo="https://i.imgur.com/xqa87lG.png" tvg-id="alert.gr" tvg-chno="32" tvg-country="GR" group-title="Greece",Alert +https://itv.streams.ovh/ALEERT/ALEERT/playlist.m3u8 +#EXTINF:-1 tvg-name="High TV" tvg-logo="https://i.imgur.com/wHzCGry.png" tvg-id="hightv.gr" tvg-chno="35" tvg-country="GR" group-title="Greece",High TV +https://live.streams.ovh/hightv/hightv/playlist.m3u8 +#EXTINF:-1 tvg-name="Kontra" tvg-logo="https://i.imgur.com/ROZ9VfV.png" tvg-id="KontraChannel.gr" tvg-chno="36" tvg-country="GR" group-title="Greece",Kontra +https://kontralive.siliconweb.com/live/kontratv/playlist.m3u8 +#EXTINF:-1 tvg-name="Naftemporiki TV" tvg-logo="https://i.imgur.com/9OFdMud.png" tvg-id="NaftemporikiTV.gr" tvg-chno="38" tvg-country="GR" group-title="Greece",Naftemporiki TV +https://telmaco.ascdn.broadpeak.io/nafteboriki/default/index.m3u8 +#EXTINF:-1 tvg-name="One Channel" tvg-logo="https://i.imgur.com/GwKaHbM.png" tvg-id="OneChannel.gr" tvg-chno="39" tvg-country="GR" group-title="Greece",One Channel +https://onechannel.siliconweb.com/one/stream/chunks_dvr.m3u8 +#EXTINF:-1 tvg-name="4E" tvg-logo="https://i.imgur.com/Ed085oJ.png" tvg-id="4E.gr" tvg-chno="51" tvg-country="GR" group-title="Greece",4E +http://eu2.tv4e.gr:1935/live/myStream.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Egnatia" tvg-logo="https://i.imgur.com/zuyYIca.png" tvg-id="egnatiatv.gr" tvg-chno="53" tvg-country="GR" group-title="Greece",Egnatia +https://video.streams.ovh:1936/egnatiatv/egnatiatv/index.m3u +#EXTINF:-1 tvg-name="Euro" tvg-logo="https://i.imgur.com/mHCk05E.png" tvg-id="eurotv.gr" tvg-chno="54" tvg-country="GR" group-title="Greece",Euro +https://live20.bozztv.com/akamaissh101/ssh101/eurotvlive/playlist.m3u8 +#EXTINF:-1 tvg-name="Gnomi" tvg-logo="https://i.imgur.com/mHCk05E.png" tvg-id="gnomitv.gr" tvg-chno="55" tvg-country="GR" group-title="Greece",Gnomi +https://live.streams.ovh:8081/gnomitv/index.m3u8 +#EXTINF:-1 tvg-name="Pella" tvg-logo="https://i.imgur.com/pwUkkGL.jpeg" tvg-id="pellatv.gr" tvg-chno="55" tvg-country="GR" group-title="Greece",Pella +https://video.streams.ovh:1936/pellatv/pellatv/playlist.m3u8 +#EXTINF:-1 tvg-name="Vergina" tvg-logo="https://i.imgur.com/cpF6wvR.png" tvg-id="verginatv.gr" tvg-chno="57" tvg-country="GR" group-title="Greece",Vergina +https://verginanews.gr:8443/hls_live/stream1.m3u8 +#EXTINF:-1 tvg-name="Best TV" tvg-logo="https://i.imgur.com/VA13E3w.png" tvg-id="besttv.gr" tvg-chno="61" tvg-country="GR" group-title="Greece",Best TV +https://besttv.siliconweb.com/bestTV/live_abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Hlektra" tvg-logo="https://i.imgur.com/LbogUPS.png" tvg-id="hlektra.gr" tvg-chno="62" tvg-country="GR" group-title="Greece",Hlektra +https://live20.bozztv.com/giatv/giatv-hlektratv/hlektratv/playlist.m3u8 +#EXTINF:-1 tvg-name="Ionian Channel" tvg-logo="https://i.imgur.com/ADVYeQd.png" tvg-id="ioniantv.gr" tvg-chno="63" tvg-country="GR" group-title="Greece",Ionian Channel +https://stream.ioniantv.gr/ionian/live_abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Lepanto" tvg-logo="https://i.imgur.com/h6Tqe0k.png" tvg-id="lepantortv.gr" tvg-chno="64" tvg-country="GR" group-title="Greece",Lepanto +https://fr.crystalweb.net:1936/lepantotv/lepantotv/playlist.m3u8 +#EXTINF:-1 tvg-name="Lychnos" tvg-logo="hhttps://i.imgur.com/JYSlBfY.png" tvg-id="lychnostv.gr" tvg-chno="65" tvg-country="GR" group-title="Greece",Lychnos +https://thor.mental-media.gr:19360/imp/imp.m3u8 +#EXTINF:-1 tvg-name="Epsilon" tvg-logo="https://i.imgur.com/vUQSDvZ.png" tvg-id="epsilon.gr" tvg-chno="71" tvg-country="GR" group-title="Greece",Epsilon +https://neon.streams.gr:8081/epsilontv/index.m3u8 +#EXTINF:-1 tvg-name="Star Central Greece" tvg-logo="https://i.imgur.com/BTUEvxg.png" tvg-id="digitalstar.gr" tvg-chno="72" tvg-country="GR" group-title="Greece",Star Central Greece +https://telmaco-cdn.akamaized.net/starcgr/default/dash/LAMIAStar-video=3000000.dash +#EXTINF:-1 tvg-name="91NRG" tvg-logo="https://i.imgur.com/g1pCRRG.png" tvg-id="nrg91.gr" tvg-chno="73" tvg-country="GR" group-title="Greece",91NRG +http://tv.nrg91.gr:1935/onweb/live/master.m3u8 +#EXTINF:-1 tvg-name="TRT" tvg-logo="https://i.imgur.com/g0jPOcC.png" tvg-id="trttv.gr" tvg-chno="83" tvg-country="GR" group-title="Greece",TRT +https://av.hellasnet.tv/rst/trt/index.m3u8 +#EXTINF:-1 tvg-name="Acheloos" tvg-logo="https://i.imgur.com/5SVMxcu.png" tvg-id="acheloostv.gr" tvg-chno="91" tvg-country="GR" group-title="Greece",Acheloos +https://acheloostv.streamings.gr/live/stream/index.m3u8 +#EXTINF:-1 tvg-name="Corfu" tvg-logo="https://i.imgur.com/dCMqo8w.jpeg" tvg-id="corfutv.gr" tvg-chno="93" tvg-country="GR" group-title="Greece",Corfu +https://itv.streams.ovh:1936/corfuchannel/corfuchannel/playlist.m3u8 +#EXTINF:-1 tvg-name="Start" tvg-logo="https://i.imgur.com/nrEtmBN.png" tvg-id="starttv.gr" tvg-chno="95" tvg-country="GR" group-title="Greece",Start +https://live.cast-control.eu/StartMedia/StartMedia/playlist.m3u8 +#EXTINF:-1 tvg-name="Center TV" tvg-logo="https://i.imgur.com/52JW71Q.png" tvg-id="CenterTV.gr" tvg-chno="101" tvg-country="GR" group-title="Greece",Center TV +https://eu1.streams.gr:8081/centertv/index.m3u8 +#EXTINF:-1 tvg-name="Delta Evros" tvg-logo="https://i.imgur.com/PDfSkRF.png" tvg-id="DeltaTV.gr" tvg-chno="102" tvg-country="GR" group-title="Greece",Delta Evros +http://81.171.10.42:1935/liveD/DStream.sdp/chunklist_w819085920.m3u8 +#EXTINF:-1 tvg-name="Thraki Net TV" tvg-logo="https://i.imgur.com/DV0I0ed.png" tvg-id="ThrakiNetTV.gr" tvg-chno="103" tvg-country="GR" group-title="Greece",Thraki Net TV +https://cdn.onestreaming.com/thrakinettv/thrakinettv/playlist.m3u8 +#EXTINF:-1 tvg-name="Creta" tvg-logo="https://i.imgur.com/x0qK8IE.png" tvg-id="CretaTV.gr" tvg-chno="111" tvg-country="GR" group-title="Greece",Creta +http://live.streams.ovh:1935/tvcreta/tvcreta/playlist.m3u8 +#EXTINF:-1 tvg-name="Kriti TV" tvg-logo="https://i.imgur.com/eLhYMmc.png" tvg-id="KritiTv.gr" tvg-chno="113" tvg-country="GR" group-title="Greece",Kriti TV +https://cretetvlive.siliconweb.com/cretetv/liveabr/playlist.m3u8 +#EXTINF:-1 tvg-name="NEA" tvg-logo="https://i.imgur.com/nvNW8G7.png" tvg-id="NeaTV.gr" tvg-chno="114" tvg-country="GR" group-title="Greece",NEA +https://live.neatv.gr:8888/hls/neatv_high/index.m3u8 +#EXTINF:-1 tvg-name="TeleKriti" tvg-logo="https://i.imgur.com/18ZYiyi.png" tvg-id="TeleKriti.gr" tvg-chno="115" tvg-country="GR" group-title="Greece",TeleKriti +https://neon.streams.gr:8081/telekriti/index.m3u8 +#EXTINF:-1 tvg-name="Samiaki TV" tvg-logo="https://i.imgur.com/aV5QoNG.png" tvg-id="CenterTV.gr" tvg-chno="121" tvg-country="GR" group-title="Greece",Samiaki TV +http://live.cast-control.eu:1935/samiaki/samiaki/playlist.m3u8 +#EXTINF:-1 tvg-name="Syros TV1" tvg-logo="https://i.imgur.com/duXHyvN.png" tvg-id="SyrosTV1.gr" tvg-chno="102" tvg-country="GR" group-title="Greece",Syros TV1 +https://eco.streams.ovh:1936/syrostv1/syrostv1/playlist.m3u8 +#EXTINF:-1 tvg-name="Hír TV" tvg-logo="https://onlinestream.live/logos/4740.png" tvg-id="HirTV.hu" tvg-chno="4" tvg-country="HU" group-title="Hungary",Hír TV +https://onlinestream.live/play.m3u?id=4740&ext=.m3u +#EXTINF:-1 tvg-name="EWTN TV" tvg-logo="https://katolikus.tv/wp-content/themes/bonum/img/ewtn-badge.jpg" tvg-id="BonumTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",EWTN TV +https://hls.iptvservice.eu/hls/ewtn-hd.m3u8 +#EXTINF:-1 tvg-name="Apostol TV" tvg-logo="https://www.apostoltv.hu/images/header-logo.png" tvg-id="ApostolTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Apostol TV +https://live.apostoltv.hu/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Oxygen Music" tvg-logo="" tvg-id="" tvg-chno="3" tvg-country="HU" group-title="Hungary",Oxygen Music +https://oxygenmusic.hu:2443/hls/oxygenmusic.m3u8 +#EXTINF:-1 tvg-name="Dance TV" tvg-logo="" tvg-id="" tvg-chno="4" tvg-country="HU" group-title="Hungary",Dance TV +https://m1b2.worldcast.tv/dancetelevisionone/2/dancetelevisionone.m3u8 +#EXTINF:-1 tvg-name="Balaton TV" tvg-logo="https://i.imgur.com/ip8L5Vt.jpg" tvg-id="BalatonTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Balaton TV +https://stream.iptvservice.eu/hls/balatontv.m3u8 +#EXTINF:-1 tvg-name="TV7 Békéscsaba" tvg-logo="https://i.imgur.com/G9Ib5K3.png" tvg-chno="7" tvg-country="HU" group-title="Hungary",TV7 Békéscsaba +https://stream.y5.hu/stream/stream_bekescsaba/stream.m3u8 +#EXTINF:-1 tvg-name="16TV" tvg-logo="http://www.16tv.hu/images/xlogo-green.png.pagespeed.ic.79XBdS6JYn.png" tvg-chno="10" tvg-country="HU" group-title="Hungary",16TV +https://cloudfront44.lexanetwork.com:1344/freerelay/16tv.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="CityTV (Belváros Lipótváros)" tvg-logo="https://www.citytv.hu/images/logo.png" tvg-chno="11" tvg-country="HU" group-title="Hungary",CityTV (Belváros Lipótváros) +https://citytv.hu/playlist.m3u8 +#EXTINF:-1 tvg-name="FehérvárTV" tvg-logo="https://www.fehervartv.hu/css/img/icon-1-2.png" tvg-chno="12" tvg-country="HU" group-title="Hungary",FehérvárTV +https://cloudfront44.lexanetwork.com:1344/freerelay/fehervartv.sdp/playlist.m3u8?key=EWSj2 +#EXTINF:-1 tvg-name="AlföldTV" tvg-logo="http://www.dealood.com/content/uploads/images/March2019/5c9721a07ea87-images-large.png" tvg-chno="13" tvg-country="HU" group-title="Hungary",AlföldTV +https://cloudfront41.lexanetwork.com:1344/relay01/livestream006.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Gyöngyös TV" tvg-logo="https://i.imgur.com/RHgaPCk.png" tvg-id="GyongyosiTV.hu" tvg-chno="14" tvg-country="HU" group-title="Hungary",Gyöngyös TV +https://cloudfront41.lexanetwork.com:1344/relay02/livestream005.sdp/playlist.m3u8?key=hkNHP +#EXTINF:-1 tvg-name="Kapos TV" tvg-logo="https://kapos.hu/static/keptar/13/b/9490.jpg" tvg-id="KaposTV.hu" tvg-chno="16" tvg-country="HU" group-title="Hungary",Kapos TV +https://cloudfront63.lexanetwork.com:1344/relay01/livestream004.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Kecskemét TV" tvg-logo="https://kecskemetitv.hu/templates/kecskemetitv/img/ktv_logo.png" tvg-id="KecskemetiTV.hu" tvg-chno="17" tvg-country="HU" group-title="Hungary",Kecskemét TV +https://eurobioinvest.hu:444/live/ktv.m3u8 +#EXTINF:-1 tvg-name="Lóverseny közvetítés" tvg-logo="https://kincsempark.hu/wp-content/uploads/2016/11/fejlec_logo_f-1.png" tvg-chno="19" tvg-country="HU" group-title="Hungary",Lóverseny közvetítés +https://cloudfront41.lexanetwork.com:1344/xrelay/loverseny2.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Zalaegerszeg TV" tvg-logo="https://zegtv.hu/wp-content/themes/assembly/images/zegtv-logo.png" tvg-id="ZalaegerszegiTV.hu" tvg-chno="20" tvg-country="HU" group-title="Hungary",Zalaegerszeg TV +https://cloudfront44.lexanetwork.com:1344/freerelay/zegtv.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Zugló TV" tvg-logo="http://zuglotv.hu/wp-content/themes/ztv/uploads/ztv_logo1.jpg" tvg-chno="21" tvg-country="HU" group-title="Hungary",Zugló TV +https://cloudfront44.lexanetwork.com:1344/freerelay/zuglotv.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="DTV" tvg-logo="https://i.imgur.com/YSpqmSO.png" tvg-id="DTV.hu" tvg-chno="23" tvg-country="HU" group-title="Hungary",DTV +http://cloudfront44.lexanetwork.com:1732/hlsrelay003/hls/livestream.sdp.m3u8 +#EXTINF:-1 tvg-name="Bajai TV" tvg-logo="https://i.imgur.com/cyReGWh.png" tvg-id="BajaiTV.hu" tvg-chno="24" tvg-country="HU" group-title="Hungary",Bajai TV +https://cloudfront41.lexanetwork.com:1344/relay01/livestream002.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Vásárhelyi Televízió" tvg-logo="https://i.imgur.com/WOEqdmx.png" tvg-id="VasarhelyiTelevizio.hu" tvg-chno="25" tvg-country="HU" group-title="Hungary",Vásárhelyi Televízió +https://stream.vasarhelyitelevizio.hu/stream/stream.m3u8 +#EXTINF:-1 tvg-name="TV Eger" tvg-logo="https://i.imgur.com/GUVW073.png" tvg-id="TVEger.hu" tvg-chno="26" tvg-country="HU" group-title="Hungary",TV Eger +http://stream.tveger.hu:8010/live.m3u8 +#EXTINF:-1 tvg-name="Oroszlányi Városi Televízió" tvg-logo="https://i.imgur.com/P0fxUH5.png" tvg-id="OroszlanyiVarosiTelevizio.hu" tvg-chno="29" tvg-country="HU" group-title="Hungary",Oroszlányi Városi Televízió +https://cloudfront44.lexanetwork.com:1344/relay01/broadcast002.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Budapest Európa TV" tvg-logo="" tvg-id="" tvg-chno="31" tvg-country="HU" group-title="Hungary",Budapest Európa TV +https://cloudfront44.lexanetwork.com:1344/freerelay/bpetv.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Héviz TV" tvg-logo="" tvg-id="" tvg-chno="32" tvg-country="HU" group-title="Hungary",Héviz TV +https://cloudfront44.lexanetwork.com:1344/relay03/livestream003.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Jászsági Térségi TV" tvg-logo="" tvg-id="" tvg-chno="33" tvg-country="HU" group-title="Hungary",Jászsági Térségi TV +https://cloudfront44.lexanetwork.com:1344/relay01/broadcast007.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Kanizsa TV" tvg-logo="" tvg-id="" tvg-chno="34" tvg-country="HU" group-title="Hungary",Kanizsa TV +https://cloudfront44.lexanetwork.com:1344/freerelay/kanizsavtv.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Líceum TV" tvg-logo="" tvg-id="" tvg-chno="35" tvg-country="HU" group-title="Hungary",Líceum TV +http://193.225.32.62:8890/live.m3u8 +#EXTINF:-1 tvg-name="RÚV" tvg-logo="https://i.imgur.com/vxaSn1K.png" tvg-id="RUV.is" tvg-chno="1" tvg-country="IS" group-title="Iceland",RÚV +https://ruv-web-live.akamaized.net/streymi/ruverl/ruverl.m3u8 +#EXTINF:-1 tvg-name="NDTV India" tvg-logo="https://i.imgur.com/QjJYohG.png" tvg-id="NDTVIndia.in" tvg-chno="1" tvg-country="IN" group-title="India",NDTV India +https://ndtvindiaelemarchana.akamaized.net/hls/live/2003679/ndtvindia/master.m3u8 +#EXTINF:-1 tvg-name="ABP News" tvg-logo="https://i.imgur.com/DKHUFVQ.png" tvg-id="[ABP News](https://abplive.com)" tvg-chno="2" tvg-country="IN" group-title="India",ABP News +https://d2l4ar6y3mrs4k.cloudfront.net/live-streaming/abpnews-livetv/master.m3u8 +#EXTINF:-1 tvg-name="ABP Ananda" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/ABP_Ananda_logo.svg/500px-ABP_Ananda_logo.svg.png" tvg-id="[ABP Ananda](https://bengali.abplive.com/)" tvg-chno="3" tvg-country="IN" group-title="India",ABP Ananda +https://d2l4ar6y3mrs4k.cloudfront.net/live-streaming/ananda-livetv/master.m3u8 +#EXTINF:-1 tvg-name="NDTV Profit" tvg-logo="https://upload.wikimedia.org/wikipedia/en/thumb/1/18/NDTV_Profit.svg/1280px-NDTV_Profit.svg.png" tvg-id="NDTVProfit.com" tvg-chno="15" tvg-country="IN" group-title="India",NDTV Profit +https://ndtvprofit.akamaized.net/hls/live/2107404/ndtvprofit/master_1.m3u8 +#EXTINF:-1 tvg-name="Al-Alam News Network Ⓢ" tvg-logo="https://i.imgur.com/UbD0Ndr.png" tvg-id="AlalamNewsChannel.ir" tvg-chno="12" tvg-country="IR" group-title="Iran",Al-Alam News Network Ⓢ +https://live2.alalam.ir/alalam.m3u8 +#EXTINF:-1 tvg-name="Al-Sharqiya News" tvg-logo="https://i.imgur.com/P6p17ZY.jpg" tvg-id="AlSharqiyaNews.iq" tvg-chno="6" tvg-country="IQ" group-title="Iraq",Al-Sharqiya News +https://5d94523502c2d.streamlock.net/alsharqiyalive/mystream/playlist.m3u8 +#EXTINF:-1 tvg-name="Al-Sharqiya" tvg-logo="https://i.imgur.com/bPYyXNf.png" tvg-id="AlSharqiya.iq" tvg-chno="7" tvg-country="IQ" group-title="Iraq",Al-Sharqiya +https://5d94523502c2d.streamlock.net/home/mystream/playlist.m3u8 +#EXTINF:-1 tvg-name="Dijlah TV" tvg-logo="https://i.imgur.com/FJEeYiz.png" tvg-id="DijlahTV.iq" tvg-chno="9" tvg-country="IQ" group-title="Iraq",Dijlah TV +https://ghaasiflu.online/Dijlah/tracks-v1a1/playlist.m3u8 +#EXTINF:-1 tvg-name="Zagros TV" tvg-logo="https://i.imgur.com/UjIuIQX.png" tvg-id="ZagrosTV.iq" tvg-chno="13" tvg-country="IQ" group-title="Iraq",Zagros TV +https://5a3ed7a72ed4b.streamlock.net/zagrostv/SMIL:myStream.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Alabbassia TV" tvg-logo="https://i.imgur.com/BPyrjO5.png" tvg-id="AlabbassiaTV.iq" tvg-chno="14" tvg-country="IQ" group-title="Iraq",Alabbassia TV +https://stream.alabbassia.com/live/alabbassia/index.m3u8 +#EXTINF:-1 tvg-name="ערוץ 13" tvg-logo="https://upload.wikimedia.org/wikipedia/he/thumb/1/17/Reshet13Logo2022.svg/559px-Reshet13Logo2022.svg.png" tvg-id="Channel13.il" tvg-chno="13" tvg-country="IL" group-title="Israel",ערוץ 13 +https://d2xg1g9o5vns8m.cloudfront.net/out/v1/0855d703f7d5436fae6a9c7ce8ca5075/index.m3u8 +#EXTINF:-1 tvg-name="La7" tvg-logo="https://i.imgur.com/F90mpSa.png" tvg-id="LA7.HD.it" tvg-chno="7" tvg-country="IT" group-title="Italy",La7 +https://d1chghleocc9sm.cloudfront.net/v1/master/3722c60a815c199d9c0ef36c5b73da68a62b09d1/cc-evfku205gqrtf/Live.m3u8 +#EXTINF:-1 tvg-name="Nove" tvg-logo="https://i.imgur.com/Hp723RU.png" tvg-id="Nove.it" tvg-chno="9" tvg-country="IT" group-title="Italy",Nove +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/NOVE/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="TV 2000" tvg-logo="https://i.imgur.com/x7RaK3a.png" tvg-id="TV2000.va" tvg-chno="28" tvg-country="IT" group-title="Italy",TV 2000 +https://hls-live-tv2000.akamaized.net/hls/live/2028510/tv2000/master.m3u8 +#EXTINF:-1 tvg-name="Real Time" tvg-logo="https://i.imgur.com/9dcTYg1.png" tvg-id="Real.Time.it" tvg-chno="31" tvg-country="IT" group-title="Italy",Real Time +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/REALTIME/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="QVC" tvg-logo="https://i.imgur.com/Ea7iUX2.png" tvg-id="QVC.it" tvg-chno="32" tvg-country="IT" group-title="Italy",QVC +https://qrg.akamaized.net/hls/live/2017383/lsqvc1it/master.m3u8 +#EXTINF:-1 tvg-name="Food Network" tvg-logo="https://i.imgur.com/i60OYr9.png" tvg-id="Food.Network.it" tvg-chno="33" tvg-country="IT" group-title="Italy",Food Network +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/FOODNETWORK/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="RTL 102.5" tvg-logo="https://i.imgur.com/KdissvS.png" tvg-id="RTL.102.5.HD.it" tvg-chno="36" tvg-country="IT" group-title="Italy",RTL 102.5 +https://dd782ed59e2a4e86aabf6fc508674b59.msvdn.net/live/S97044836/tbbP8T1ZRPBL/playlist.m3u8 +#EXTINF:-1 tvg-name="Discovery Channel" tvg-logo="https://i.imgur.com/5IxIFJ0.png" tvg-id="Discovery.Channel.it" tvg-chno="37" tvg-country="IT" group-title="Italy",Discovery Channel +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/DISCOVERYCHANNEL/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="Giallo" tvg-logo="https://i.imgur.com/0PIRwZS.png" tvg-id="Giallo.TV.it" tvg-chno="38" tvg-country="IT" group-title="Italy",Giallo +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/GIALLO/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="K2" tvg-logo="https://i.imgur.com/wlLgSiA.png" tvg-id="K2.it" tvg-chno="41" tvg-country="IT" group-title="Italy",K2 +https://d1pmpe0hs35ka5.cloudfront.net/v1/master/3722c60a815c199d9c0ef36c5b73da68a62b09d1/cc-39hsskpppgf72/K2_IT.m3u8 +#EXTINF:-1 tvg-name="Frisbee" tvg-logo="https://i.imgur.com/9y1zIAe.png" tvg-id="Frisbee.it" tvg-chno="44" tvg-country="IT" group-title="Italy",Frisbee +https://d6m7lubks416z.cloudfront.net/v1/master/3722c60a815c199d9c0ef36c5b73da68a62b09d1/cc-zmbstsedxme9s/Frisbee_IT.m3u8 +#EXTINF:-1 tvg-name="Super!" tvg-logo="https://i.imgur.com/1124YEp.png" tvg-id="Super!.it" tvg-chno="47" tvg-country="IT" group-title="Italy",Super! +https://495c5a85d9074f29acffeaea9e0215eb.msvdn.net/super/super_main/super_main_hbbtv/playlist.m3u8 +#EXTINF:-1 tvg-name="DMAX" tvg-logo="https://i.imgur.com/dmEmRX7.png" tvg-id="DMAX.it" tvg-chno="52" tvg-country="IT" group-title="Italy",DMAX +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/DMAX/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="HGTV – Home & Garden Tv" tvg-logo="https://i.imgur.com/emLNC0U.png" tvg-id="HGTVItaly.it" tvg-chno="56" tvg-country="IT" group-title="Italy",HGTV – Home & Garden Tv +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/HGTV/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="Turbo" tvg-logo="https://i.imgur.com/TjjVjV0.png" tvg-id="Motor.Trend.it" tvg-chno="59" tvg-country="IT" group-title="Italy",Turbo +https://streaming.aurora.enhanced.live/live/mpc/enl0eG1mLmVncmVzcy5mZDJ2eHI=/out/v1/IT_SIMULCAST/TURBO/CMAF/index.m3u8 +#EXTINF:-1 tvg-name="Travel TV" tvg-logo="https://i.imgur.com/aXAUyLN.png" tvg-id="TravelTV.it" tvg-chno="61" tvg-country="IT" group-title="Italy",Travel TV +https://streaming.softwarecreation.it/GoldTvSat/GoldTvSat/playlist.m3u8 +#EXTINF:-1 tvg-name="SuperTennis" tvg-logo="https://i.imgur.com/GzsPlbX.png" tvg-id="SuperTennis.it" tvg-chno="64" tvg-country="IT" group-title="Italy",SuperTennis +https://live-embed.supertennix.hiway.media/restreamer/supertennix_client/gpu-a-c0-16/restreamer/outgest/aa3673f1-e178-44a9-a947-ef41db73211a/manifest.m3u8 +#EXTINF:-1 tvg-name="Alma TV" tvg-logo="https://i.imgur.com/Y8JiDwN.png" tvg-id="AlmaTV.it" tvg-chno="65" tvg-country="IT" group-title="Italy",Alma TV +https://streaming.softwarecreation.it/AlmaTv/AlmaTv/playlist.m3u8 +#EXTINF:-1 tvg-name="R101 TV Ⓖ" tvg-logo="https://i.imgur.com/mWeEa9T.png" tvg-id="R101TV.it" tvg-chno="67" tvg-country="IT" group-title="Italy",R101 TV Ⓖ +https://live02-seg.msr.cdn.mediaset.net/live/ch-er/er-clr.isml/index.m3u8 +#EXTINF:-1 tvg-name="Deejay TV" tvg-logo="https://i.imgur.com/rlaKH6k.png" tvg-id="DeejayTV.it" tvg-chno="69" tvg-country="IT" group-title="Italy",Deejay TV +https://4c4b867c89244861ac216426883d1ad0.msvdn.net/live/S85984808/sMO0tz9Sr2Rk/playlist.m3u8 +#EXTINF:-1 tvg-name="RadioItaliaTV" tvg-logo="https://i.imgur.com/4VCEJuJ.png" tvg-id="RadioItaliaTV.it" tvg-chno="70" tvg-country="IT" group-title="Italy",RadioItaliaTV +https://radioitaliatv.akamaized.net/hls/live/2093117/RadioitaliaTV/master.m3u8 +#EXTINF:-1 tvg-name="Radio KISS KISS TV" tvg-logo="https://i.imgur.com/UTStxDW.png" tvg-id="KissKissTV.it" tvg-chno="158" tvg-country="IT" group-title="Italy",Radio KISS KISS TV +https://kk.fluid.stream/KKMulti/smil:KissKissTV.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTL 102.5 Traffic" tvg-logo="https://i.imgur.com/GH7byqm.jpeg" tvg-id="RTL1025Traffic.it" tvg-chno="233" tvg-country="IT" group-title="Italy",RTL 102.5 Traffic +https://dd782ed59e2a4e86aabf6fc508674b59.msvdn.net/live/S38122967/2lyQRIAAGgRR/playlist.m3u8 +#EXTINF:-1 tvg-name="MAN-GA" tvg-logo="https://i.imgur.com/8a6WYU2.png" tvg-id="Manga.it" tvg-chno="236" tvg-country="IT" group-title="Italy",MAN-GA +https://c65b9e710bde44819015af98e72cd7ab.msvdn.net/live/S93572876/aILSQPYFY3pF/playlist.m3u +#EXTINF:-1 tvg-name="Radio24-IlSole24OreTV" tvg-logo="https://i.imgur.com/NTqrdWW.png" tvg-id="Radio24TV.it" tvg-chno="246" tvg-country="IT" group-title="Italy",Radio24-IlSole24OreTV +https://ilsole24ore-radiovisual.akamaized.net/hls/live/2035302/persidera/master.m3u8 +#EXTINF:-1 tvg-name="BeJoy.Kids" tvg-logo="https://i.imgur.com/zuR9Go5.png" tvg-id="BeJoyKids.it" tvg-chno="247" tvg-country="IT" group-title="Italy",BeJoy.Kids +https://64b16f23efbee.streamlock.net/bejoy/bejoy/playlist.m3u8 +#EXTINF:-1 tvg-name="RadioFreccia" tvg-logo="https://i.imgur.com/J5N9F7Z.png" tvg-id="RadioFrecciaTV.it" tvg-chno="258" tvg-country="IT" group-title="Italy",RadioFreccia +https://dd782ed59e2a4e86aabf6fc508674b59.msvdn.net/live/S3160845/0tuSetc8UFkF/playlist.m3u8 +#EXTINF:-1 tvg-name="RDS Social TV" tvg-logo="https://i.imgur.com/TY6FhqI.png" tvg-id="RDSSocialTV.it" tvg-chno="265" tvg-country="IT" group-title="Italy",RDS Social TV +https://stream.rdstv.radio/index.m3u8 +#EXTINF:-1 tvg-name="Radio ZETA" tvg-logo="https://i.imgur.com/0MgCm1n.png" tvg-id="RadioZetaTV.it" tvg-chno="266" tvg-country="IT" group-title="Italy",Radio ZETA +https://dd782ed59e2a4e86aabf6fc508674b59.msvdn.net/live/S9346184/XEx1LqlYbNic/playlist.m3u8 +#EXTINF:-1 tvg-name="Senato TV" tvg-logo="https://i.imgur.com/FoQoNZW.png" tvg-id="SenatoTV.it" tvg-chno="89" tvg-country="IT" group-title="Italy",Senato TV +https://senato-live.morescreens.com/SENATO_1_001/playlist.m3u8 +#EXTINF:-1 tvg-name="Camera dei Deputati Ⓢ" tvg-logo="https://i.imgur.com/fqGn1k9.png" tvg-id="CameradeiDeputati.it" tvg-chno="90" tvg-country="IT" group-title="Italy",Camera dei Deputati Ⓢ +https://video-ar.radioradicale.it/diretta/camera2/playlist.m3u8 +#EXTINF:-1 tvg-name="UniNettuno University TV Ⓖ" tvg-logo="https://i.imgur.com/BOGMeio.png" tvg-id="UniNettunoUniversityTV.it" tvg-chno="701" tvg-country="IT" group-title="Italy",UniNettuno University TV Ⓖ +https://stream6-rai-it.akamaized.net/live/uninettuno/playlist.m3u8 +#EXTINF:-1 tvg-name="111 Tv" tvg-logo="https://i.imgur.com/4jY8yAI.png" tvg-country="IT" group-title="Italy",111 Tv +https://5db313b643fd8.streamlock.net/111/111/playlist.m3u8 +#EXTINF:-1 tvg-name="12 Tv Parma" tvg-logo="https://i.imgur.com/xnUgx6b.png" tvg-id="12TVParma.it" tvg-country="IT" group-title="Italy",12 Tv Parma +https://5929b138b139d.streamlock.net/12TVParma/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="4 You Tv" tvg-logo="https://i.imgur.com/NN0cSbz.png" tvg-country="IT" group-title="Italy",4 You Tv +https://streamsrv2.nets-sr.com:19360/4youtv/4youtv.m3u8 +#EXTINF:-1 tvg-name="Alpauno" tvg-logo="https://i.imgur.com/4QKFtUa.png" tvg-country="IT" group-title="Italy",Alpauno +https://5f22d76e220e1.streamlock.net/alpaunotv/alpaunotv/playlist.m3u8 +#EXTINF:-1 tvg-name="Alto Adige Tv" tvg-logo="https://i.imgur.com/S2sCFQi.png" tvg-id="AltoAdigeTV.it" tvg-country="IT" group-title="Italy",Alto Adige Tv +https://5f204aff97bee.streamlock.net/AltoAdigeTV/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Antenna 2 Bergamo" tvg-logo="https://i.imgur.com/NfvHIAw.png" tvg-country="IT" group-title="Italy",Antenna 2 Bergamo +https://58f12ffd2447a.streamlock.net/Antenna2/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Antenna 3 Massa" tvg-logo="https://i.imgur.com/CHDU86j.png" tvg-country="IT" group-title="Italy",Antenna 3 Massa +https://media2021.rtvweb.com/antenna3massa/a3/playlist.m3u8 +#EXTINF:-1 tvg-name="Antenna 3 Veneto Nord Est" tvg-logo="https://i.imgur.com/NiVHLwp.png" tvg-id="AntennaTreNordest.it" tvg-country="IT" group-title="Italy",Antenna 3 Veneto Nord Est +https://59d7d6f47d7fc.streamlock.net/antennatreveneto/antennatreveneto.stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Antenna Sicilia" tvg-logo="https://i.imgur.com/8DOdqsE.png" tvg-chno="10" tvg-country="IT" group-title="Italy",Antenna Sicilia +https://antennasicilialive.cdn.netrw.it/antennasicilialive/hls/ccr/antennasicilia/master.m3u8 +#EXTINF:-1 tvg-name="Arte Network Orler" tvg-logo="https://i.imgur.com/DP5y0Er.png" tvg-country="IT" group-title="Italy",Arte Network Orler +https://tsw.streamingwebtv24.it:1936/artenetwork/artenetwork/playlist.m3u8 +#EXTINF:-1 tvg-name="Aurora Arte" tvg-logo="https://i.imgur.com/BoLZ5wG.png" tvg-id="AuroraArte.it" tvg-country="IT" group-title="Italy",Aurora Arte +https://59d7d6f47d7fc.streamlock.net/auroraarte/auroraarte/playlist.m3u8 +#EXTINF:-1 tvg-name="Bergamo Tv" tvg-logo="https://i.imgur.com/1doR6Vl.png" tvg-country="IT" group-title="Italy",Bergamo Tv +https://db142859fd5541b09de25d6507f1f2d3.msvdn.net/live/S17501676/oIxAsgEEA46M/playlist.m3u8?DVR +#EXTINF:-1 tvg-name="BOM Channel" tvg-logo="https://i.imgur.com/hISoOK3.png" tvg-id="BomChannel.it" tvg-country="IT" group-title="Italy",BOM Channel +https://5f22d76e220e1.streamlock.net/canale6/canale6/playlist.m3u8 +#EXTINF:-1 tvg-name="Cafe Tv 24" tvg-logo="https://i.imgur.com/KbcbxFw.png" tvg-country="IT" group-title="Italy",Cafe Tv 24 +https://srvx1.selftv.video/cafe/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 10" tvg-logo="https://i.imgur.com/KuQcjYV.png" tvg-country="IT" group-title="Italy",Canale 10 +https://nrvideo1.newradio.it:1936/desxcerbht/desxcerbht/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 2" tvg-logo="https://i.imgur.com/ETqDkS1.png" tvg-country="IT" group-title="Italy",Canale 2 +https://59d7d6f47d7fc.streamlock.net/canale2/canale2/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 21" tvg-logo="https://i.imgur.com/mU6Cq89.png" tvg-id="Canale21.it" tvg-country="IT" group-title="Italy",Canale 21 +https://0ff9dd7fe9b64bc08a5fc4ed525454c3.msvdn.net/live/S38994111/B7j0ql4XaZtE/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 21 Extra" tvg-logo="https://i.imgur.com/cDAsrBl.png" tvg-id="Canale21Extra.it" tvg-country="IT" group-title="Italy",Canale 21 Extra +https://0ff9dd7fe9b64bc08a5fc4ed525454c3.msvdn.net/live/S42170132/sT6C3LFaD1iA/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 7" tvg-logo="https://i.imgur.com/9cuOLCn.png" tvg-country="IT" group-title="Italy",Canale 7 +http://wms.shared.streamshow.it:80/canale7/canale7/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale 8 Campania" tvg-logo="https://i.imgur.com/ElAS2WC.png" tvg-country="IT" group-title="Italy",Canale 8 Campania +https://59d7d6f47d7fc.streamlock.net/canale8/canale8/playlist.m3u8 +#EXTINF:-1 tvg-name="Canale Italia" tvg-logo="https://i.imgur.com/QlwLRyh.png" tvg-id="CanaleItalia.it" tvg-country="IT" group-title="Italy",Canale Italia +https://ovp-live.akamaized.net/ac115_live/canale1.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Casa Italia 53" tvg-logo="https://i.imgur.com/O4GQVCk.png" tvg-country="IT" group-title="Italy",Casa Italia 53 +https://ovp-live.akamaized.net/ac115_live/canale3.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Casa Sanremo Tv" tvg-logo="https://i.imgur.com/WL3SFTs.png" tvg-country="IT" group-title="Italy",Casa Sanremo Tv +https://router.xdevel.com/video0s975911-633/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Castrovillari Tv" tvg-logo="https://i.imgur.com/V0kjYNG.png" tvg-country="IT" group-title="Italy",Castrovillari Tv +http://msh0062.stream.seeweb.it/live/flv:stream00.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Cittaceleste Tv" tvg-logo="https://i.imgur.com/9RVriNK.jpeg" tvg-country="IT" group-title="Italy",Cittaceleste Tv +https://sportitaliaamd.akamaized.net/live/Cittaceleste/hls/A990687F506536598442FC5CD12C97CB78873FBA/index.m3u8 +#EXTINF:-1 tvg-name="Company Tv" tvg-logo="https://i.imgur.com/IbabUDd.png" tvg-country="IT" group-title="Italy",Company Tv +https://company.fluid.stream/CompanyTV/smil:Company_ALL.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Cremona 1" tvg-logo="https://i.imgur.com/a5d0F01.jpg" tvg-country="IT" group-title="Italy",Cremona 1 +https://cdn2.streamshow.it/cloud-cremona1/cremona1/playlist.m3u8 +#EXTINF:-1 tvg-name="Cusano Italia Tv" tvg-logo="https://i.imgur.com/9F1sVjZ.png" tvg-country="IT" group-title="Italy",Cusano Italia Tv +https://router.xdevel.com/video0s975363-69/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Cusano News 7" tvg-logo="https://i.imgur.com/L49Ie1Q.png" tvg-country="IT" group-title="Italy",Cusano News 7 +https://router.xdevel.com/video1s975363-1596/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Delta Tv" tvg-logo="https://i.imgur.com/mfwVXt7.png" tvg-country="IT" group-title="Italy",Delta Tv +http://hbbtv-server.zivoli.it:8080/hls/deltatv/deltatv/index.m3u8 +#EXTINF:-1 tvg-name="Deluxe 139" tvg-logo="https://i.imgur.com/kRexw3w.png" tvg-country="IT" group-title="Italy",Deluxe 139 +https://59d7d6f47d7fc.streamlock.net/pierstyle/pierstyle/playlist.m3u8 +#EXTINF:-1 tvg-name="Di.Tv 80" tvg-logo="https://i.imgur.com/kHxTsJw.png" tvg-country="IT" group-title="Italy",Di.Tv 80 +https://5f22d76e220e1.streamlock.net/ditv80/ditv80/playlist.m3u8 +#EXTINF:-1 tvg-name="Di.Tv 92" tvg-logo="https://i.imgur.com/g3SI08H.png" tvg-country="IT" group-title="Italy",Di.Tv 92 +https://media2021.rtvweb.com/di_tv_92/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Digital Tv7 Benevento" tvg-logo="https://i.imgur.com/NaQkklP.png" tvg-country="IT" group-title="Italy",Digital Tv7 Benevento +http://streaming.senecadot.com/live/flv:tv7.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="E'live Brescia Tv" tvg-logo="https://i.imgur.com/bZ3B7pi.png" tvg-country="IT" group-title="Italy",E'live Brescia Tv +https://59d7d6f47d7fc.streamlock.net/elivebresciatv/elivebresciatv/playlist.m3u8 +#EXTINF:-1 tvg-name="Easy Tv Canale 190" tvg-logo="https://i.imgur.com/LKrVuRR.jpg" tvg-country="IT" group-title="Italy",Easy Tv Canale 190 +https://diretta.arcapuglia.it:8080/live/easytv/index.m3u8 +#EXTINF:-1 tvg-name="Espansione Tv" tvg-logo="https://i.imgur.com/mm9HKpD.png" tvg-id="EspansioneTV.it" tvg-country="IT" group-title="Italy",Espansione Tv +https://srvx1.selftv.video/espansione/smil:live.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Esperia Tv" tvg-logo="https://patbuweb.com/tivustream/chanlogoz/ita/esperiatv.png" tvg-country="IT" group-title="Italy",Esperia Tv +https://59d7d6f47d7fc.streamlock.net/esperiatv/esperiatv/playlist.m3u8 +#EXTINF:-1 tvg-name="Etna Espresso Channel" tvg-logo="https://i.imgur.com/hMUxytZ.png" tvg-country="IT" group-title="Italy",Etna Espresso Channel +https://5db313b643fd8.streamlock.net/Etnachannelponte/Etnachannelponte/playlist.m3u8 +#EXTINF:-1 tvg-name="è Tv Marche" tvg-logo="https://i.imgur.com/vxgbFnR.png" tvg-country="IT" group-title="Italy",è Tv Marche +https://live.ipstream.it/etvmarche/etvmarche.stream/playlist.m3u8 +#EXTINF:-1 tvg-name="è Tv Rete7" tvg-logo="https://i.imgur.com/FXFzJhM.png" tvg-country="IT" group-title="Italy",è Tv Rete7 +https://live.ipstream.it/etv/etv.stream/playlist.m3u8 +#EXTINF:-1 tvg-name="è Tv Umbria" tvg-logo="https://i.imgur.com/DASRCe2.png" tvg-country="IT" group-title="Italy",è Tv Umbria +https://live.ipstream.it/etvumbria/etvumbria.stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Extra Tv" tvg-logo="https://i.imgur.com/KCBurST.png" tvg-country="IT" group-title="Italy",Extra Tv +https://rst2.saiuzwebnetwork.it:8081/extratvlive/index.m3u8 +#EXTINF:-1 tvg-name="Fano Tv" tvg-logo="https://i.imgur.com/orqEzJ6.png" tvg-country="IT" group-title="Italy",Fano Tv +https://diretta.arcapuglia.it:8080/live/fanotv/index.m3u8 +#EXTINF:-1 tvg-name="GRP Televisione" tvg-logo="https://i.imgur.com/1zNPpVE.png" tvg-country="IT" group-title="Italy",GRP Televisione +https://webstream.multistream.it/memfs/a3195c96-f884-4c74-924f-2648814fc0b5.m3u8 +#EXTINF:-1 tvg-name="Giovanni Paolo Tv" tvg-logo="https://i.imgur.com/GH5eJE6.png" tvg-country="IT" group-title="Italy",Giovanni Paolo Tv +https://media2021.rtvweb.com/giovannipaolotv/web/chunklist_w663456797.m3u8 +#EXTINF:-1 tvg-name="Globo Tv" tvg-logo="https://i.imgur.com/lR08pb3.jpeg" tvg-country="IT" group-title="Italy",Globo Tv +https://nrvideo1.newradio.it:1936/awrgaebshj/awrgaebshj/playlist.m3u8 +#EXTINF:-1 tvg-name="Gold Tv" tvg-logo="https://i.imgur.com/3rVi4kD.png" tvg-id="GoldTV.it" tvg-country="IT" group-title="Italy",Gold Tv +https://streaming.softwarecreation.it/GoldTv/GoldTv/playlist.m3u8 +#EXTINF:-1 tvg-name="GO-TV Channel" tvg-logo="https://i.imgur.com/xgjrAAn.png" tvg-id="GOTVCanale163.it" tvg-country="IT" group-title="Italy",GO-TV Channel +https://6zklxkbbdw9b-hls-live.mariatvcdn.it/msmotor/2f759512164fc6fe4acbed6a5648993a.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="GS Channel" tvg-logo="https://i.imgur.com/ExfxCRv.png" tvg-country="IT" group-title="Italy",GS Channel +https://rst.saiuzwebnetwork.it:8081/retereggio/index.m3u8 +#EXTINF:-1 tvg-name="Icaro Tv Rimini" tvg-logo="https://i.imgur.com/z05VSSN.png" tvg-country="IT" group-title="Italy",Icaro Tv Rimini +https://59d7d6f47d7fc.streamlock.net/icarotv/icarotv/playlist.m3u8 +#EXTINF:-1 tvg-name="il61" tvg-logo="https://i.imgur.com/1rUZsBv.png" tvg-country="IT" group-title="Italy",il61 +https://5a1178b42cc03.streamlock.net/travel/travel/playlist.m3u8 +#EXTINF:-1 tvg-name="Italia 2 Tv" tvg-logo="https://i.imgur.com/ISbxfY0.png" tvg-id="Italia2TV.it" tvg-country="IT" group-title="Italy",Italia 2 Tv +https://59d7d6f47d7fc.streamlock.net/italia2/italia2/playlist.m3u8 +#EXTINF:-1 tvg-name="Italia 7" tvg-logo="https://i.imgur.com/YBXkY4w.png" tvg-country="IT" group-title="Italy",Italia 7 +https://streaming.softwarecreation.it/Italia7/Italia7/playlist.m3u8 +#EXTINF:-1 tvg-name="La Nuova Tv" tvg-logo="https://i.imgur.com/wg8FhdN.png" tvg-country="IT" group-title="Italy",La Nuova Tv +https://5dcabf026b188.streamlock.net/lanuovatvbas/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="La Tenda Tv" tvg-logo="https://i.imgur.com/XnBp0hT.png" tvg-country="IT" group-title="Italy",La Tenda Tv +https://2-fss-1.streamhoster.com/pl_148/206202-2980948-1/playlist.m3u8 +#EXTINF:-1 tvg-name="La tr3 Marsala" tvg-logo="https://i.imgur.com/XlxpfEx.png" tvg-country="IT" group-title="Italy",La tr3 Marsala +https://tsw.streamingwebtv24.it:1936/eslife1/eslife1/playlist.m3u8 +#EXTINF:-1 tvg-name="LaC News 24" tvg-logo="https://i.imgur.com/02vCECa.png" tvg-country="IT" group-title="Italy",LaC News 24 +https://f5842579ff984c1c98d63b8d789673eb.msvdn.net/live/S27391994/HVvPMzy/playlist.m3u8 +#EXTINF:-1 tvg-name="LaC Tv Calabria" tvg-logo="https://i.imgur.com/2Ef6crS.png" tvg-country="IT" group-title="Italy",LaC Tv Calabria +https://f5842579ff984c1c98d63b8d789673eb.msvdn.net/live/S47282891/JWjL3xqPf4bX/playlist.m3u8 +#EXTINF:-1 tvg-name="Lab Tv" tvg-logo="https://i.imgur.com/OpRS6Fl.png" tvg-country="IT" group-title="Italy",Lab Tv +https://customer-yzibk50951uq418a.cloudflarestream.com/a58893dee4b9922a75e41b4ec4243f84/manifest/video.m3u8 +#EXTINF:-1 tvg-name="Lazio Tv" tvg-logo="https://i.imgur.com/DAj5Uwb.png" tvg-country="IT" group-title="Italy",Lazio Tv +https://streaming.softwarecreation.it/LazioTv/LazioTv/playlist.m3u8 +#EXTINF:-1 tvg-name="Le Cronache Lucane Tv" tvg-logo="https://i.imgur.com/EBY3IZL.jpg" tvg-country="IT" group-title="Italy",Le Cronache Lucane Tv +http://stucazz.com:8888/hls/cronache.m3u8 +#EXTINF:-1 tvg-name="Lira Tv" tvg-logo="https://i.imgur.com/S0ReVEo.png" tvg-country="IT" group-title="Italy",Lira Tv +https://a928c0678d284da5b383f29ecc5dfeec.msvdn.net/live/S57315730/8kTBWibNteJA/playlist.m3u8 +#EXTINF:-1 tvg-name="Made in BO" tvg-logo="https://i.imgur.com/WFnrMS0.png" tvg-country="IT" group-title="Italy",Made in BO +https://srvx1.selftv.video/dmchannel/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Maria Vision" tvg-logo="https://i.imgur.com/fdx5YXi.png" tvg-country="IT" group-title="Italy",Maria Vision +https://1601580044.rsc.cdn77.org/live/_jcn_/amls:CHANNEL_2/playlist.m3u8 +#EXTINF:-1 tvg-name="NTI Canale 271" tvg-logo="https://i.imgur.com/zlmcUe0.jpg" tvg-country="IT" group-title="Italy",NTI Canale 271 +https://www.ntimedia.it/video/S0B/S0B_master.m3u8 +#EXTINF:-1 tvg-name="Nuova TV Nazionale" tvg-logo="https://i.imgur.com/QWlRuXg.png" tvg-country="IT" group-title="Italy",Nuova TV Nazionale +https://stream4.xdevel.com/video0s975955-782/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Nuova Tv 1" tvg-logo="https://i.imgur.com/1yqTZhR.png" tvg-country="IT" group-title="Italy",Nuova Tv 1 +https://nuovatv.net:8443/tv/stream.m3u8 +#EXTINF:-1 tvg-name="Odeon 24" tvg-logo="https://i.imgur.com/M1tVBuH.png" tvg-country="IT" group-title="Italy",Odeon 24 +https://streaming.softwarecreation.it/Odeon/Odeon/manifest.m3u8 +#EXTINF:-1 tvg-name="Onda Novara Tv" tvg-logo="https://i.imgur.com/Qoh9CFy.png" tvg-country="IT" group-title="Italy",Onda Novara Tv +https://585b674743bbb.streamlock.net/9006/9006/playlist.m3u8 +#EXTINF:-1 tvg-name="Onda Tv Sicilia" tvg-logo="https://i.imgur.com/0c5Y6lr.png" tvg-country="IT" group-title="Italy",Onda Tv Sicilia +https://5926fc9c7c5b2.streamlock.net/9040/9040/playlist.m3u8 +#EXTINF:-1 tvg-name="Onda Web Radio" tvg-logo="https://i.imgur.com/3hTvrC8.jpg" tvg-country="IT" group-title="Italy",Onda Web Radio +http://178.33.224.197:1935/ondaradioweb/ondaradioweb/playlist.m3u8 +#EXTINF:-1 tvg-name="Orler Tv" tvg-logo="https://i.imgur.com/dBkxD8e.png" tvg-country="IT" group-title="Italy",Orler Tv +https://w1.mediastreaming.it/orlertv/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Padre Pio Tv" tvg-logo="https://i.imgur.com/7ajxEPH.png" tvg-country="IT" group-title="Italy",Padre Pio Tv +https://600f07e114306.streamlock.net/PadrePioTV/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Parole di Vita" tvg-logo="https://i.imgur.com/M9mIiZD.png" tvg-country="IT" group-title="Italy",Parole di Vita +https://64b16f23efbee.streamlock.net/paroledivita/paroledivita/playlist.m3u8 +#EXTINF:-1 tvg-name="Peer Tv Alto Adige" tvg-logo="https://www.peer.biz/peertv-iptv/peer-tv-alto-adige.png" tvg-id="PeerTV.it" tvg-country="IT" group-title="Italy",Peer Tv Alto Adige +https://iptv.peer.biz/live/peertv-it.m3u8 +#EXTINF:-1 tvg-name="Peer TV Südtirol" tvg-logo="https://www.peer.biz/peertv-iptv/peer-tv-suedtirol.png" tvg-id="PeerTV.de" tvg-chno="2" tvg-country="IT" group-title="Italy",Peer TV Südtirol +https://iptv.peer.biz/live/peertv.m3u8 +#EXTINF:-1 tvg-name="Prima Tv Napoli" tvg-logo="https://i.imgur.com/yPuQeEy.jpg" tvg-country="IT" group-title="Italy",Prima Tv Napoli +https://57068da1deb21.streamlock.net/primatvnapoli/primatvnapoli/playlist.m3u8 +#EXTINF:-1 tvg-name="Primocanale" tvg-logo="https://i.imgur.com/xWF1A1U.png" tvg-country="IT" group-title="Italy",Primocanale +https://msh0203.stream.seeweb.it/live/flv:stream2.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Promovideo Tv" tvg-logo="https://i.imgur.com/MwK9HVG.png" tvg-country="IT" group-title="Italy",Promovideo Tv +https://media2021.rtvweb.com/promovideo_web/promovideo/playlist.m3u8 +#EXTINF:-1 tvg-name="Quarto Canale Flegreo" tvg-logo="https://i.imgur.com/8RKY3Du.png" tvg-country="IT" group-title="Italy",Quarto Canale Flegreo +http://live.mariatvcdn.com/dialogos/171e41deedf405f10c7dd6311387fb43.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio 3M InBlu" tvg-logo="https://i.imgur.com/d7O7Uqa.png" tvg-country="IT" group-title="Italy",Radio 3M InBlu +https://stream.mariatvcdn.com/telemistrettaradio/900bfcc0f9012ea272584fd5ff5281b8.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio 51 Tv" tvg-logo="https://uaznao.com/wp-content/uploads/2023/03/radio51.png" tvg-country="IT" group-title="Italy",Radio 51 Tv +https://59d7d6f47d7fc.streamlock.net/canale51/canale51/chunklist_w1193883900.m3u8 +#EXTINF:-1 tvg-name="Radio Birikina Tv" tvg-logo="https://uaznao.com/wp-content/uploads/2023/03/radiobirikina.png" tvg-country="IT" group-title="Italy",Radio Birikina Tv +https://tvd-bk.fluid.stream/RadioBirikinaTV/livestream/chunklist_w84398277.m3u8 +#EXTINF:-1 tvg-name="Radio Bruno Tv" tvg-logo="https://i.imgur.com/y4vKE83.png" tvg-country="IT" group-title="Italy",Radio Bruno Tv +https://router.xdevel.com/video0s975758-473/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio Ibiza" tvg-logo="https://i.imgur.com/uu0DHY5.png" tvg-country="IT" group-title="Italy",Radio Ibiza +https://str48.fluid.stream/RadioIbizaTV/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio Immagine Tv" tvg-logo="https://i.imgur.com/iQlXRAB.png" tvg-country="IT" group-title="Italy",Radio Immagine Tv +https://media.velcom.it:8081/RadioImmagineTV/index.fmp4.m3u8 +#EXTINF:-1 tvg-name="Radio Italia Cina Tv" tvg-logo="https://i.imgur.com/QGkyrO3.png" tvg-country="IT" group-title="Italy",Radio Italia Cina Tv +https://585b674743bbb.streamlock.net/9054/9054/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio Libertà" tvg-logo="https://i.imgur.com/HRvPlf5.png" tvg-country="IT" group-title="Italy",Radio Libertà +https://router.xdevel.com/video0s975360-67/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Radio m2o Tv" tvg-logo="https://i.imgur.com/wl30UFj.png" tvg-id="m2oTV.it" tvg-country="IT" group-title="Italy",Radio m2o Tv +https://4c4b867c89244861ac216426883d1ad0.msvdn.net/live/S62628868/uhdWBlkC1AoO/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio Monte Kronio Tv (R.M.K.)" tvg-logo="https://i.imgur.com/t0I2Shi.jpg" tvg-country="IT" group-title="Italy",Radio Monte Kronio Tv (R.M.K.) +https://648026e87a75e.streamlock.net/rmktv/rmktv/playlist.m3u8 +#EXTINF:-1 tvg-name="Radio Norba Tv" tvg-logo="https://i.imgur.com/qftBPM9.png" tvg-id="RadionorbaTV.it" tvg-country="IT" group-title="Italy",Radio Norba Tv +https://router.xdevel.com/video0s975885-462/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Radio Piter Pan Tv" tvg-logo="https://uaznao.com/wp-content/uploads/2023/03/radiopiterpan.png" tvg-country="IT" group-title="Italy",Radio Piter Pan Tv +https://tvd-piter.fluid.stream/RadioPiterpanTV/livestream/chunklist_w1866496033.m3u8 +#EXTINF:-1 tvg-name="Radio Radio Tv" tvg-logo="https://i.imgur.com/iKuRg2b.png" tvg-id="RadioRadioTV.it" tvg-country="IT" group-title="Italy",Radio Radio Tv +https://200912.global.ssl.fastly.net/646b335e2291a2022444bb7c/live_22f84390fe1411ed919df3da85a483cc/rewind-14400.m3u8 +#EXTINF:-1 tvg-name="Radio Radiosa Tv" tvg-logo="https://i.imgur.com/8kqyxvz.png" tvg-country="IT" group-title="Italy",Radio Radiosa Tv +https://stream7.zivoli.it/radiosatv/radiosatv/playlist.m3u8 +#EXTINF:-1 tvg-name="Reggio Tv" tvg-logo="https://i.imgur.com/merrg2C.png" tvg-country="IT" group-title="Italy",Reggio Tv +https://cdn10.streamshow.it/cloud-reggiotv/reggiotv/playlist.m3u8 +#EXTINF:-1 tvg-name="Rete 55" tvg-logo="https://i.imgur.com/EsZn2cj.png" tvg-country="IT" group-title="Italy",Rete 55 +https://live1.giocabet.tv/stream/6/index.m3u8 +#EXTINF:-1 tvg-name="Rete Biella Tv" tvg-logo="https://i.imgur.com/e2ryHx7.png" tvg-country="IT" group-title="Italy",Rete Biella Tv +https://sb.top-ix.org/retebiella/streaming/playlist.m3u8 +#EXTINF:-1 tvg-name="Rete Oro Tv" tvg-logo="https://i.imgur.com/OCxGtwA.png" tvg-country="IT" group-title="Italy",Rete Oro Tv +https://5926fc9c7c5b2.streamlock.net/9094/9094/playlist.m3u8 +#EXTINF:-1 tvg-name="Rete Sole" tvg-logo="https://i.imgur.com/u0ezKgE.png" tvg-country="IT" group-title="Italy",Rete Sole +https://5926fc9c7c5b2.streamlock.net/9332/9332/playlist.m3u8 +#EXTINF:-1 tvg-name="Rete Tv Italia" tvg-logo="https://i.imgur.com/lXGWoV9.png" tvg-country="IT" group-title="Italy",Rete Tv Italia +https://57068da1deb21.streamlock.net/retetvitalia/retetvitalia/playlist.m3u8 +#EXTINF:-1 tvg-name="Rete Veneta" tvg-logo="https://i.imgur.com/cInhQFp.png" tvg-country="IT" group-title="Italy",Rete Veneta +http://wms.shared.streamshow.it/reteveneta/reteveneta/playlist.m3u8 +#EXTINF:-1 tvg-name="Rossini Tv" tvg-logo="https://i.imgur.com/K0Em0nx.jpg" tvg-country="IT" group-title="Italy",Rossini Tv +https://stream.rossinitv.it/memfs/3bca4ad7-adfc-4610-ac0e-e826743ddc57.m3u8 +#EXTINF:-1 tvg-name="RTC Quarta Rete" tvg-logo="https://i.imgur.com/rFGA6qL.png" tvg-country="IT" group-title="Italy",RTC Quarta Rete +https://msh0232.stream.seeweb.it/live/stream00.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="RTC Telecalabria" tvg-logo="https://i.imgur.com/tTYLcuh.jpg" tvg-id="RTCTelecalabria.it" tvg-country="IT" group-title="Italy",RTC Telecalabria +https://w1.mediastreaming.it/calabriachannel/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="RTI Calabria" tvg-logo="https://i.imgur.com/hVzEvmo.jpg" tvg-country="IT" group-title="Italy",RTI Calabria +https://stream.ets-sistemi.it:8081/rticalabria/index.m3u8 +#EXTINF:-1 tvg-name="RTL 102.5 + Plus" tvg-logo="https://i.imgur.com/mPqDtCO.png" tvg-id="RTL1025Plus.it" tvg-country="IT" group-title="Italy",RTL 102.5 + Plus +https://streamcdng14-dd782ed59e2a4e86aabf6fc508674b59.msvdn.net/live/S82929343/cAcsSu4Wecc5/chunklist_b5256000.m3u8 +#EXTINF:-1 tvg-name="Rtp Tv" tvg-logo="https://i.imgur.com/I1hYI0C.png" tvg-country="IT" group-title="Italy",Rtp Tv +http://flash2.xdevel.com/rtptv/rtptv/playlist.m3u8 +#EXTINF:-1 tvg-name="Rttr" tvg-logo="https://i.imgur.com/z7xMArA.png" tvg-country="IT" group-title="Italy",Rttr +https://5f204aff97bee.streamlock.net/RTTRlive/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Rtv 38 Toscana" tvg-logo="https://i.imgur.com/xqlhJqK.png" tvg-country="IT" group-title="Italy",Rtv 38 Toscana +https://845d8509d2cb4f249dd0b2ae5755b6c2.msvdn.net/rtv38/rtv38_live_main/mainabr/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Sardegna 1" tvg-logo="https://i.imgur.com/YNEW2h2.png" tvg-country="IT" group-title="Italy",Sardegna 1 +https://7e1cc2454f2242afabe05cc0a2f483cd.msvdn.net/live/S30721796/ZS3Xu8mn5f0J/playlist.m3u8 +#EXTINF:-1 tvg-name="Set Tv Cilento" tvg-logo="https://i.imgur.com/qN5D1jD.png" tvg-country="IT" group-title="Italy",Set Tv Cilento +https://stream1.aswifi.it/settv/live/index.m3u8 +#EXTINF:-1 tvg-name="Sesta Rete" tvg-logo="https://i.imgur.com/0B0S2gI.png" tvg-country="IT" group-title="Italy",Sesta Rete +https://stream10.xdevel.com/video0s977089-1792/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Sienatv" tvg-logo="https://i.imgur.com/gcysky4.png" tvg-country="IT" group-title="Italy",Sienatv +https://router.xdevel.com/video0s976727-1441/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Sophia Tv" tvg-logo="https://i.imgur.com/fiLNK3b.jpg" tvg-country="IT" group-title="Italy",Sophia Tv +https://bild-und-ton.stream/sophiatv-it/smil:sophia-tv-it.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Stiletv" tvg-logo="https://i.imgur.com/ZP3cJi7.png" tvg-country="IT" group-title="Italy",Stiletv +https://proxy.media.convergenze.it/stiletv/streams/oQOFd7JglHjO1631525551097.m3u8 +#EXTINF:-1 tvg-name="Super J Tv" tvg-logo="https://i.imgur.com/5oy5Nuu.png" tvg-country="IT" group-title="Italy",Super J Tv +https://59d39900ebfb8.streamlock.net/SuperJtv/SuperJtv/playlist.m3u8 +#EXTINF:-1 tvg-name="Supertv" tvg-logo="https://i.imgur.com/7gUZcEh.png" tvg-country="IT" group-title="Italy",Supertv +http://wms.shared.streamshow.it:1935/supertv/supertv/live.m3u8 +#EXTINF:-1 tvg-name="T9" tvg-logo="https://i.imgur.com/XzL05Py.png" tvg-country="IT" group-title="Italy",T9 +https://streaming.softwarecreation.it/tnove/tnove/playlist.m3u8 +#EXTINF:-1 tvg-name="TRC Santeramo" tvg-logo="https://i.imgur.com/VbYdS8P.jpg" tvg-country="IT" group-title="Italy",TRC Santeramo +https://stream7.livinlive.it/trc/trc/playlist.m3u8 +#EXTINF:-1 tvg-name="TSD Tv Arezzo(Tele San Domenico)" tvg-logo="https://i.imgur.com/WQ8eQXc.png" tvg-country="IT" group-title="Italy",TSD Tv Arezzo(Tele San Domenico) +https://stream.mariatvcdn.com/tsd/7c59373bfdb38201b9215ff86f0ce6af.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tcf Tv" tvg-logo="https://i.imgur.com/fiylFs2.jpg" tvg-country="IT" group-title="Italy",Tcf Tv +https://stream10.xdevel.com/video1s977294-1864/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Abruzzo Tv" tvg-logo="https://i.imgur.com/koB8J4b.jpg" tvg-country="IT" group-title="Italy",Tele Abruzzo Tv +http://uk4.streamingpulse.com:1935/TeleabruzzoTV/TeleabruzzoTV/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Bari" tvg-logo="https://i.imgur.com/HYSz4rx.png" tvg-country="IT" group-title="Italy",Tele Bari +https://w1.mediastreaming.it/telebari/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Boario" tvg-logo="https://i.imgur.com/LlLD3L6.png" tvg-country="IT" group-title="Italy",Tele Boario +https://stream7.xdevel.com/video0s976425-1244/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Tele Bruzzano" tvg-logo="https://i.imgur.com/7TWbCDt.jpg" tvg-country="IT" group-title="Italy",Tele Bruzzano +https://playerssl.telemia.tv/fileadmin/hls/Telebruzzano/telebruzzano_mediachunks.m3u8 +#EXTINF:-1 tvg-name="Tele Chiara" tvg-logo="https://i.imgur.com/Q5XpnXR.png" tvg-country="IT" group-title="Italy",Tele Chiara +http://fms.tvavicenza.it:1935/telechiara/diretta/playlist.m3u8 +#EXTINF:-1 tvg-name="Telecolor" tvg-logo="https://i.imgur.com/urUG78J.png" tvg-id="TelecolorLombardia.it" tvg-country="IT" group-title="Italy",Telecolor +https://1aadf145546f475282c5b4e658c0ac4b.msvdn.net/live/324149/hlbAWtl/playlist.m3u8 +#EXTINF:-1 tvg-name="Telecolor Sicilia" tvg-logo="https://i.imgur.com/rfNg6fw.png" tvg-chno="11" tvg-country="IT" group-title="Italy",Telecolor Sicilia +https://antennasicilialive.cdn.netrw.it/antennasicilialive/hls/ccr/telecolor/master.m3u8 +#EXTINF:-1 tvg-name="Tele Foggia" tvg-logo="https://i.imgur.com/M7tqBu9.jpg" tvg-country="IT" group-title="Italy",Tele Foggia +https://59d7d6f47d7fc.streamlock.net/telefoggia/telefoggia/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Friuli" tvg-logo="https://i.imgur.com/AoQxZxD.png" tvg-country="IT" group-title="Italy",Tele Friuli +https://5757bf2aa08e42248fb9b9d620f5d900.msvdn.net/live/S11646715/pE3ax0lT0rBd/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Ischia" tvg-logo="https://i.imgur.com/vihHVQn.jpg" tvg-country="IT" group-title="Italy",Tele Ischia +https://rst.saiuzwebnetwork.it:8081/teleischia/index.m3u8 +#EXTINF:-1 tvg-name="Tele Jonio" tvg-logo="https://i.imgur.com/qJeDV8R.png" tvg-country="IT" group-title="Italy",Tele Jonio +http://59d7d6f47d7fc.streamlock.net/telejonio/telejonio/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Mantova" tvg-logo="https://i.imgur.com/bkSPcs4.png" tvg-country="IT" group-title="Italy",Tele Mantova +https://5ce9406b73c33.streamlock.net/TeleMantova/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Mia" tvg-logo="https://i.imgur.com/SdXpCwL.png" tvg-country="IT" group-title="Italy",Tele Mia +https://playerssl.telemia.tv/fileadmin/hls/TelemiaHD/telemia85_mediachunks.m3u8 +#EXTINF:-1 tvg-name="Tele Mistretta" tvg-logo="https://i.imgur.com/OJ3zUS0.png" tvg-country="IT" group-title="Italy",Tele Mistretta +https://live.mariatvcdn.com/telemistretta/8fbcd205ada81b295ee6c211c3a80dde.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Molise" tvg-logo="https://i.imgur.com/u5VD0x9.png" tvg-country="IT" group-title="Italy",Tele Molise +http://185.202.128.1:1935/Telemolise4K/Telemolise4K/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele One" tvg-logo="https://i.imgur.com/9trB6mj.png" tvg-country="IT" group-title="Italy",Tele One +https://648026e87a75e.streamlock.net/teleone/teleone/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Pace Trento" tvg-logo="https://i.imgur.com/o5sQCpF.png" tvg-country="IT" group-title="Italy",Tele Pace Trento +https://5a1178b42cc03.streamlock.net/telepacetrento/telepacetrento/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Pavia" tvg-logo="https://i.imgur.com/YVodo4T.png" tvg-country="IT" group-title="Italy",Tele Pavia +http://wms.shared.streamshow.it:1935/telepavia/telepavia/live.m3u8 +#EXTINF:-1 tvg-name="Tele Piadena" tvg-logo="https://i.imgur.com/VqaPuQN.png" tvg-country="IT" group-title="Italy",Tele Piadena +https://stream3.xdevel.com/video0s975441-151/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Tele Quattro Trieste" tvg-logo="https://i.imgur.com/MFxQxve.png" tvg-country="IT" group-title="Italy",Tele Quattro Trieste +http://wms.shared.streamshow.it/telequattro/telequattro/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Radio Pace 1" tvg-logo="https://i.imgur.com/ORkkXM8.png" tvg-country="IT" group-title="Italy",Tele Radio Pace 1 +https://jk3lz2bwlw79-hls-live.mariatvcdn.it/teleradiopace1/efcc8fc46cab26315ce3f5845d76008f.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Radio Pace 2" tvg-logo="https://i.imgur.com/U4E3eEr.png" tvg-country="IT" group-title="Italy",Tele Radio Pace 2 +https://zkpywrbgdbeg-hls-live.mariatvcdn.it/teleradiopace2/254c9b5c52a73a94ef0f6169cbd05dc2.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Radio Pace 3" tvg-logo="https://i.imgur.com/pTqIxFD.png" tvg-country="IT" group-title="Italy",Tele Radio Pace 3 +https://932y4273djv8-hls-live.mariatvcdn.it/teleradiopace3/d2274c22e9ee09eb2eda01ed0496f8f5.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Radio Pace 4" tvg-logo="https://i.imgur.com/KPHHsN5.png" tvg-country="IT" group-title="Italy",Tele Radio Pace 4 +https://j78dpr7nyq5r-hls-live.mariatvcdn.it/teleradiopace4/13d74f2cfe921bfbc262697203d47d8f.sdp/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele Tricolore" tvg-logo="https://i.imgur.com/A2XouAd.png" tvg-country="IT" group-title="Italy",Tele Tricolore +https://59d7d6f47d7fc.streamlock.net/rs2/rs2/playlist.m3u8 +#EXTINF:-1 tvg-name="Tele8 Tv" tvg-logo="https://i.imgur.com/dM0HE6O.png" tvg-country="IT" group-title="Italy",Tele8 Tv +https://57068da1deb21.streamlock.net/teletv8/teletv8/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleitalia 41" tvg-logo="https://i.imgur.com/lTuPSOn.png" tvg-country="IT" group-title="Italy",Teleitalia 41 +https://streaming.softwarecreation.it/teleitalia/teleitalia/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleleonessa" tvg-logo="https://i.imgur.com/jq3etlV.png" tvg-country="IT" group-title="Italy",Teleleonessa +http://wms.shared.streamshow.it:1935/teleleonessa/teleleonessa/live.m3u8 +#EXTINF:-1 tvg-name="TeleMajg" tvg-logo="https://i.imgur.com/9tefonp.jpg" tvg-country="IT" group-title="Italy",TeleMajg +https://59d7d6f47d7fc.streamlock.net/telemajg/telemajg/playlist.m3u8 +#EXTINF:-1 tvg-name="Telerama" tvg-logo="https://i.imgur.com/enfqUlI.jpg" tvg-country="IT" group-title="Italy",Telerama +https://58d921499d3d3.streamlock.net/TeleRama/livestream/playlist.m3u8 +#EXTINF:-1 tvg-name="TeleRegione" tvg-logo="https://i.imgur.com/i5WozUP.png" tvg-country="IT" group-title="Italy",TeleRegione +https://streaming.softwarecreation.it/TR118/TR118/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleromagna" tvg-logo="https://i.imgur.com/4jWadI8.png" tvg-country="IT" group-title="Italy",Teleromagna +https://livetr.teleromagna.it/teleromagna/live/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleromagna 24" tvg-logo="https://i.imgur.com/Bpml478.png" tvg-country="IT" group-title="Italy",Teleromagna 24 +https://livetr.teleromagna.it/mia/live/playlist.m3u8 +#EXTINF:-1 tvg-name="TeleRomaDue" tvg-logo="https://i.imgur.com/78hA7ma.png" tvg-country="IT" group-title="Italy",TeleRomaDue +https://57068da1deb21.streamlock.net/teletibur/teletibur/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleuniverso" tvg-logo="https://i.imgur.com/u8E9wBb.png" tvg-country="IT" group-title="Italy",Teleuniverso +https://stream.ets-sistemi.it:19360/teleuniverso/teleuniverso.m3u8 +#EXTINF:-1 tvg-name="Tuscia Sabina 2000Tv" tvg-logo="https://i.imgur.com/Tq5nEAy.png" tvg-country="IT" group-title="Italy",Tuscia Sabina 2000Tv +http://ts2000tv.streaming.nextware.it:8081/ts2000tv/ts2000tv/chunks.m3u8 +#EXTINF:-1 tvg-name="Tv 12" tvg-logo="https://i.imgur.com/oxv08pJ.png" tvg-country="IT" group-title="Italy",Tv 12 +https://59d7d6f47d7fc.streamlock.net/tv12/tv12/playlist.m3u8 +#EXTINF:-1 tvg-name="Tv Luna Napoli" tvg-logo="https://i.imgur.com/jxhuoyE.png" tvg-country="IT" group-title="Italy",Tv Luna Napoli +https://diretta.arcapuglia.it:8080/live/teleluna/index.m3u8 +#EXTINF:-1 tvg-name="Tv Prato" tvg-logo="https://i.imgur.com/zDeVpZd.png" tvg-country="IT" group-title="Italy",Tv Prato +https://live.mariatvcdn.com/tvprato/2db0dd5674586686a867ec52c3aa8e06.sdp/mono.m3u8 +#EXTINF:-1 tvg-name="Tv Qui Modena" tvg-logo="https://i.imgur.com/4bOYlfg.png" tvg-country="IT" group-title="Italy",Tv Qui Modena +https://59d7d6f47d7fc.streamlock.net/tvqui/tvqui/playlist.m3u8 +#EXTINF:-1 tvg-name="Tv Yes" tvg-logo="https://i.imgur.com/1wsO8U7.png" tvg-country="IT" group-title="Italy",Tv Yes +https://stream1.aswifi.it/radioyes/live/index.m3u8 +#EXTINF:-1 tvg-name="Tva Vicenza" tvg-logo="https://i.imgur.com/FtFuPCC.png" tvg-country="IT" group-title="Italy",Tva Vicenza +http://fms.tvavicenza.it:1935/live/diretta_1/playlist.m3u8 +#EXTINF:-1 tvg-name="Tvrs Tv" tvg-logo="https://i.imgur.com/6p7hTmY.jpg" tvg-country="IT" group-title="Italy",Tvrs Tv +https://cdn8.streamshow.it/cloud-tvrs/tvrs/playlist.m3u8 +#EXTINF:-1 tvg-name="Umbria Tv" tvg-logo="https://i.imgur.com/XKCVEmK.png" tvg-country="IT" group-title="Italy",Umbria Tv +https://umbriatv.stream.rubidia.it:8083/live/umbriatv/playlist.m3u8 +#EXTINF:-1 tvg-name="Uno4 Tv" tvg-logo="https://i.imgur.com/wfPPpBA.png" tvg-country="IT" group-title="Italy",Uno4 Tv +https://cdn.uno4.it/index.m3u8 +#EXTINF:-1 tvg-name="Video Calabria" tvg-logo="https://i.imgur.com/Bc2AvIm.png" tvg-id="VideoCalabria.it" tvg-country="IT" group-title="Italy",Video Calabria +https://a8a02dd9a49a4fc9810743615c65ab73.msvdn.net/live/S76734991/i6NeNqLYaspb/playlist.m3u8 +#EXTINF:-1 tvg-name="Video Mediterraneo" tvg-logo="https://i.imgur.com/hJHC3uQ.png" tvg-country="IT" group-title="Italy",Video Mediterraneo +https://bfbe5f347ac4424faf719dda285bc39e.msvdn.net/live/S54897858/4gWO7tTzpK3N/playlist.m3u8 +#EXTINF:-1 tvg-name="Video Nola" tvg-logo="https://i.imgur.com/M5z5UoD.jpg" tvg-country="IT" group-title="Italy",Video Nola +https://videonola.aswifi.it/videonolaabr/live.m3u8 +#EXTINF:-1 tvg-name="Video Regione Sicilia" tvg-logo="https://i.imgur.com/0szwnR3.png" tvg-country="IT" group-title="Italy",Video Regione Sicilia +https://57214cb172d84f8cb311b91513952b03.msvdn.net/live/S40896499/ZuaLjACsWIL4/playlist.m3u8 +#EXTINF:-1 tvg-name="Videonovara" tvg-logo="https://i.imgur.com/NnO7E5I.png" tvg-country="IT" group-title="Italy",Videonovara +https://ed04.top-ix.org/avtvlive/videonovara/streaming/playlist.m3u8 +#EXTINF:-1 tvg-name="Videotelecarnia" tvg-logo="https://i.imgur.com/r4K9JHW.png" tvg-country="IT" group-title="Italy",Videotelecarnia +https://rst2.saiuzwebnetwork.it:8081/vtccarnia/index.m3u8 +#EXTINF:-1 tvg-name="ショップチャンネル" tvg-logo="https://i.imgur.com/CCMAF7W.png" tvg-id="ShopChannel.jp" tvg-chno="CS055" tvg-country="JP" group-title="日本 / Japan",ショップチャンネル +https://stream3.shopch.jp/HLS/master.m3u8 +#EXTINF:-1 tvg-name="ウェザーニュースLiVE" tvg-logo="https://channel.rakuten.co.jp/service/img/logo/chlogo-with-number/106_whethernews.png" tvg-id="rch_45" tvg-chno="CH 106" tvg-country="JP" group-title="日本 / Japan",ウェザーニュースLiVE +https://rch01e-alive-hls.akamaized.net/38fb45b25cdb05a1/out/v1/4e907bfabc684a1dae10df8431a84d21/index.m3u8 +#EXTINF:-1 tvg-name="TOKYO MX チャンネル" tvg-logo="https://channel.rakuten.co.jp/service/img/logo/chlogo-with-number/108_mx.png" tvg-id="" tvg-chno="CH 108" tvg-country="JP" group-title="日本 / Japan",TOKYO MX チャンネル +https://cdn-uw2-prod.tsv2.amagi.tv/linear/amg01287-rakutentvjapan-tokyomx-cmaf-rakutenjp/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS 1 Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/EBS_1TV_Logo.svg/512px-EBS_1TV_Logo.svg.png" tvg-id="EBS1TV.kr" tvg-chno="4" tvg-country="KR" group-title="Korea",EBS 1 Ⓢ +https://ebsonair.ebs.co.kr/ebs1familypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS 2 Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/EBS_2TV_Logo.svg/512px-EBS_2TV_Logo.svg.png" tvg-id="EBS2TV.kr" tvg-chno="5" tvg-country="KR" group-title="Korea",EBS 2 Ⓢ +https://ebsonair.ebs.co.kr/ebs2familypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="CJB TV" tvg-logo="https://i.imgur.com/MvxdZhX.png" tvg-id="CJBTV.kr" tvg-chno="14" tvg-country="KR" group-title="Korea",CJB TV +http://1.222.207.80:1935/live/cjbtv/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS Kids Ⓢ" tvg-logo="https://i.imgur.com/62oo8Bx.png" tvg-id="EBSKids.kr" tvg-chno="19" tvg-country="KR" group-title="Korea",EBS Kids Ⓢ +https://ebsonair.ebs.co.kr/ebsufamilypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS Plus 1 Ⓢ" tvg-logo="https://i.imgur.com/ImUHRG2.png" tvg-id="EBSPlus1.kr" tvg-chno="20" tvg-country="KR" group-title="Korea",EBS Plus 1 Ⓢ +https://ebsonair.ebs.co.kr/plus1familypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS Plus 2 Ⓢ" tvg-logo="https://i.imgur.com/mgFRZFq.png" tvg-id="EBSPlus2.kr" tvg-chno="21" tvg-country="KR" group-title="Korea",EBS Plus 2 Ⓢ +https://ebsonair.ebs.co.kr/plus2familypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="EBS English Ⓢ" tvg-logo="https://i.imgur.com/qceaIf7.png" tvg-id="EBSEnglish.kr" tvg-chno="22" tvg-country="KR" group-title="Korea",EBS English Ⓢ +https://ebsonair.ebs.co.kr/plus3familypc/familypc1m/playlist.m3u8 +#EXTINF:-1 tvg-name="ReTV" tvg-logo="https://upload.wikimedia.org/wikipedia/lv/thumb/d/db/ReTV_Logo_2022.svg/320px-ReTV_Logo_2022.svg.png" tvg-id="ReTV.lv" tvg-chno="11" tvg-country="LV" group-title="Latvia",ReTV +https://retv2132.bstrm.net/slive/_definst_/retv_retv_channel_5k7_42787_default_891_hls.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Vidusdaugavas Televīzija" tvg-logo="https://i.imgur.com/L5U3PQR.png" tvg-id="VidusdaugavasTelevizija.lv" tvg-chno="2" tvg-country="LV" group-title="Latvia",Vidusdaugavas Televīzija +https://straume.vdtv.lv/vdtv2/index.m3u8 +#EXTINF:-1 tvg-name="LRT Lituanica" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d2/LRT_Lituanica_Logo_2022.svg/640px-LRT_Lituanica_Logo_2022.svg.png" tvg-id="LRTLituanica.lt" tvg-chno="3" tvg-country="LT" group-title="Lithuania",LRT Lituanica +https://stream-live.lrt.lt/lituanica/master.m3u8 +#EXTINF:-1 tvg-name="Lietuvos Rytas TV" tvg-logo="https://i.imgur.com/5wpxVI0.png" tvg-id="LietuvosRytasTV.lt" tvg-chno="9" tvg-country="LT" group-title="Lithuania",Lietuvos Rytas TV +https://live.lietuvosryto.tv/live/hls/eteris.m3u8 +#EXTINF:-1 tvg-name="Delfi TV" tvg-logo="https://i.imgur.com/IFoHP5M.png" tvg-id="DelfiTV.lt" tvg-chno="10" tvg-country="LT" group-title="Lithuania",Delfi TV +https://s1.dcdn.lt/live/televizija/playlist.m3u8 +#EXTINF:-1 tvg-name="RTL Télé Lëtzebuerg" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c0/RTL_Luxembourg_2023.svg/640px-RTL_Luxembourg_2023.svg.png" tvg-id="RTLTeleLuxembourg.lu" tvg-chno="1" tvg-country="LU" group-title="Luxembourg",RTL Télé Lëtzebuerg +https://live-edge.rtl.lu/channel1/smil:channel1/playlist.m3u8 +#EXTINF:-1 tvg-name="RTL Zwee" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/RTL_Zwee_2023.svg/1024px-RTL_Zwee_2023.svg.png" tvg-id="RTLTeleLuxembourg.lu" tvg-chno="2" tvg-country="LU" group-title="Luxembourg",RTL Zwee +https://live-edge.rtl.lu/channel2/smil:channel2/playlist.m3u8 +#EXTINF:-1 tvg-name="Chamber TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/Logo_of_the_Chamber_of_Deputies_of_Luxembourg.svg/2560px-Logo_of_the_Chamber_of_Deputies_of_Luxembourg.svg.png" tvg-id="ChamberTV.lu" tvg-chno="3" tvg-country="LU" group-title="Luxembourg",Chamber TV +https://media02.webtvlive.eu/chd-edge/_definst_/smil:chamber_tv_hd.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Alcarria TV" tvg-logo="https://i.imgur.com/zNSuxVZ.jpg" tvg-id="AlcarriaTV.es" tvg-chno="1" tvg-country="MX" group-title="Mexico",Alcarria TV +http://cls.alcarria.tv/live/alcarriatv-livestream.m3u8 +#EXTINF:-1 tvg-name="Sor TV" tvg-logo="https://i.imgur.com/BcfZgD8.png" tvg-id="SorTV.md" tvg-chno="1" tvg-country="MD" group-title="Moldova",Sor TV +http://188.237.212.16:8888/live/cameraFeed.m3u8 +#EXTINF:-1 tvg-name="TVCG MNE" tvg-logo="https://i.imgur.com/pf8VEwf.png" tvg-id="TVCGMNE.me" tvg-chno="99" tvg-country="ME" group-title="Montenegro",TVCG MNE +https://rtcg-live-open.morescreens.com/RTCG_1_004/playlist.m3u8 +#EXTINF:-1 tvg-name="Omrop Fryslân" tvg-logo="https://i.imgur.com/0VKJLAK.png" tvg-id="OmropFryslanTV.nl" tvg-chno="1" tvg-country="NL" group-title="Netherlands",Omrop Fryslân +https://d3pvma9xb2775h.cloudfront.net/live/omropfryslan/f8f68bd5/playlist.m3u8 +#EXTINF:-1 tvg-name="RTV Noord" tvg-logo="https://i.imgur.com/pO5Mexa.png" tvg-id="RTVNoord.nl" tvg-chno="2" tvg-country="NL" group-title="Netherlands",RTV Noord +https://media.rtvnoord.nl/live/rtvnoord/tv/playlist.m3u8 +#EXTINF:-1 tvg-name="RTV Oost" tvg-logo="https://i.imgur.com/8ropV29.png" tvg-id="RTVOost.nl" tvg-chno="4" tvg-country="NL" group-title="Netherlands",RTV Oost +https://d34cg2bnc08ruf.cloudfront.net/live/rtvoost/tv/index.m3u8 +#EXTINF:-1 tvg-name="Omroep Gelderland" tvg-logo="https://i.imgur.com/TPlyvUQ.png" tvg-id="OmroepGelderlandTV.nl" tvg-chno="5" tvg-country="NL" group-title="Netherlands",Omroep Gelderland +https://d2od87akyl46nm.cloudfront.net/live/omroepgelderland/tvgelderland-hls/index.m3u8 +#EXTINF:-1 tvg-name="RTV Utrecht" tvg-logo="https://i.imgur.com/c0I24N6.png" tvg-id="RTVUtrecht.nl" tvg-chno="6" tvg-country="NL" group-title="Netherlands",RTV Utrecht +http://media.rtvutrecht.nl/live/rtvutrecht/rtvutrecht/index.m3u8 +#EXTINF:-1 tvg-name="Omroep Flevoland Ⓢ" tvg-logo="https://i.imgur.com/d1CmTcI.png" tvg-id="OmroepFlevolandTV.nl" tvg-chno="7" tvg-country="NL" group-title="Netherlands",Omroep Flevoland Ⓢ +https://d5ms27yy6exnf.cloudfront.net/live/omroepflevoland/tv/index.m3u8 +#EXTINF:-1 tvg-name="RTV Rijnmond" tvg-logo="https://i.imgur.com/TNhUVEm.png" tvg-id="RTVRijnmond.nl" tvg-chno="9" tvg-country="NL" group-title="Netherlands",RTV Rijnmond +https://dcur8bjarl5c2.cloudfront.net/live/rijnmond/tv/index.m3u8 +#EXTINF:-1 tvg-name="Omroep West" tvg-logo="https://i.imgur.com/aax1HPH.png" tvg-id="OmroepWestTV.nl" tvg-chno="10" tvg-country="NL" group-title="Netherlands",Omroep West +https://d1axml5ozykh3g.cloudfront.net/live/omroepwest/tv/index.m3u8 +#EXTINF:-1 tvg-name="Omroep Zeeland" tvg-logo="https://i.imgur.com/8aLDyUI.png" tvg-id="OmroepZeelandTV.nl" tvg-chno="11" tvg-country="NL" group-title="Netherlands",Omroep Zeeland +http://d3isaxd2t6q8zm.cloudfront.net/live/omroepzeeland/tv/index.m3u8 +#EXTINF:-1 tvg-name="Omroep Brabant" tvg-logo="https://i.imgur.com/Jv7IjHJ.png" tvg-id="OmroepBrabantTV.nl" tvg-chno="12" tvg-country="NL" group-title="Netherlands",Omroep Brabant +http://d3slqp9xhts6qb.cloudfront.net/live/omroepbrabant/tv/index.m3u8 +#EXTINF:-1 tvg-name="L1 Ⓢ" tvg-logo="https://i.imgur.com/Jyhn1iP.png" tvg-id="L1TV.nl" tvg-chno="13" tvg-country="NL" group-title="Netherlands",L1 Ⓢ +http://d34pj260kw1xmk.cloudfront.net/live/l1/tv/index.m3u8 +#EXTINF:-1 tvg-name="TV 21 Ⓢ Ⓖ" tvg-logo="https://i.imgur.com/IC1f4h3.png" tvg-id="TV21.mk" tvg-chno="3" tvg-country="MK" group-title="North Macedonia",TV 21 Ⓢ Ⓖ +https://t2.tvmak.com/tv/TV21MK.m3u8?QScgsh2FfM +#EXTINF:-1 tvg-name="Телевизија Здравкин" tvg-logo="https://i.imgur.com/kSmcAER.png" tvg-id="Zdravkin" tvg-chno="1" tvg-country="MK" group-title="North Macedonia",Телевизија Здравкин +http://zdravkin.hugo.mk:1935/live/zdravkin/playlist.m3u8 +#EXTINF:-1 tvg-name="ТВ НОВА 12" tvg-logo="https://i.imgur.com/Qo3Hj3t.png" tvg-id="tvnova12.mk" tvg-chno="14" tvg-country="MK" group-title="North Macedonia",ТВ НОВА 12 +http://151.236.247.171:8080/nova/index.m3u8 +#EXTINF:-1 tvg-name="Наша ТВ" tvg-logo="https://i.imgur.com/EOLrXvB.png" tvg-id="NasaTV.mk" tvg-chno="1" tvg-country="MK" group-title="North Macedonia",Наша ТВ +https://stream.nasatv.com.mk/hls/nasatv_live.m3u8 +#EXTINF:-1 tvg-name="Cool TV" tvg-logo="https://i.imgur.com/2tFrjUz.png" tvg-id="CoolTV.mk" tvg-chno="2" tvg-country="MK" group-title="North Macedonia",Cool TV +https://stream.nasatv.com.mk/cooltv/hls/cooltv_live.m3u8 +#EXTINF:-1 tvg-name="Folk TV" tvg-logo="https://i.imgur.com/4b9aZ9P.png" tvg-id="FolkTV.mk" tvg-chno="3" tvg-country="MK" group-title="North Macedonia",Folk TV +https://stream.nasatv.com.mk/folktv/hls/folktv_live.m3u8 +#EXTINF:-1 tvg-name="Jazz TV" tvg-logo="https://i.imgur.com/4U6Ju5G.png" tvg-id="JazzTV.mk" tvg-chno="4" tvg-country="MK" group-title="North Macedonia",Jazz TV +https://stream.nasatv.com.mk/jazztv/hls/jazztv_live.m3u8 +#EXTINF:-1 tvg-name="Love TV" tvg-logo="https://i.imgur.com/B8iaejQ.png" tvg-id="LoveTV.mk" tvg-chno="5" tvg-country="MK" group-title="North Macedonia",Love TV +https://stream.nasatv.com.mk/lovetv/hls/lovetv_live.m3u8 +#EXTINF:-1 tvg-name="Rock TV" tvg-logo="https://i.imgur.com/Y9miDQo.png" tvg-id="RockTV.mk" tvg-chno="6" tvg-country="MK" group-title="North Macedonia",Rock TV +https://stream.nasatv.com.mk/rocktv/hls/rocktv_live.m3u8 +#EXTINF:-1 tvg-name="Sky Folk" tvg-logo="https://i.imgur.com/xRw4Hmu.png" tvg-id="SkyFolkTV.mk" tvg-chno="8" tvg-country="MK" group-title="North Macedonia",Sky Folk +https://skyfolk.mk/live.m3u8 +#EXTINF:-1 tvg-name="Folk Club TV" tvg-logo="https://i.imgur.com/vkGFSl8.png" tvg-id="FolkClubTV.mk" tvg-chno="10" tvg-country="MK" group-title="North Macedonia",Folk Club TV +http://tv1.intv.mk:1935/live2/folkklub/index.m3u8 +#EXTINF:-1 tvg-name="INTV" tvg-logo="https://i.imgur.com/K7BSjqY.png" tvg-id="" tvg-chno="11" tvg-country="MK" group-title="North Macedonia",INTV +http://tv1.intv.mk:1935/live/intv/index.m3u8 +#EXTINF:-1 tvg-name="Вистел" tvg-logo="https://i.imgur.com/MbM0E6L.png" tvg-id="" tvg-chno="13" tvg-country="MK" group-title="North Macedonia",Вистел +https://live.vtv.mk/live/vtv/chunks.m3u8 +#EXTINF:-1 tvg-name="Frikanalen" tvg-logo="https://i.imgur.com/rY3Owxl.png" tvg-id="Frikanalen.no" tvg-chno="50" tvg-country="NO" group-title="Norway",Frikanalen +https://frikanalen.no/stream/index.m3u8 +#EXTINF:-1 tvg-name="4fun.tv Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/4fun.tv_Logo_%282017%29.svg/640px-4fun.tv_Logo_%282017%29.svg.png" tvg-id="4FunTV.pl" tvg-country="PL" group-title="Poland",4fun.tv Ⓢ +https://stream.4fun.tv:8888/hls/4f_high/index.m3u8 +#EXTINF:-1 tvg-name="RTP1" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/RTP1_-_Logo_2016.svg/640px-RTP1_-_Logo_2016.svg.png" tvg-id="RTP1.pt" tvg-chno="1" tvg-country="PT" group-title="Portugal",RTP1 +https://streaming-live.rtp.pt/liverepeater/smil:rtp1HD.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTP2 Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/en/4/4d/Rtp2_2016_logo.png" tvg-id="RTP2.pt" tvg-chno="2" tvg-country="PT" group-title="Portugal",RTP2 Ⓖ +https://streaming-live.rtp.pt/liverepeater/rtp2HD.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTP Açores" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/RTP_A%C3%A7ores_%282016%29.svg/640px-RTP_A%C3%A7ores_%282016%29.svg.png" tvg-id="RTPAcores.pt" tvg-chno="5" tvg-country="PT" group-title="Portugal",RTP Açores +https://streaming-live.rtp.pt/liverepeater/smil:rtpacoresHD.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTP Madeira Ⓢ" tvg-logo="https://upload.wikimedia.org/wikipedia/en/a/ac/RTP_Madeira_2016.png" tvg-id="RTPMadeira.pt" tvg-chno="5" tvg-country="PT" group-title="Portugal",RTP Madeira Ⓢ +https://streaming-live.rtp.pt/liverepeater/smil:rtpmadeira.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTP3" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/b/b9/Rtp3.png" tvg-id="RTP3.pt" tvg-chno="6" tvg-country="PT" group-title="Portugal",RTP3 +https://streaming-live.rtp.pt/livetvhlsDVR/rtpnHDdvr.smil/playlist.m3u8?DVR= +#EXTINF:-1 tvg-name="RTP África" tvg-logo="https://i.imgur.com/ISFNy17.png" tvg-id="RTPAfrica.pt" tvg-chno="8" tvg-country="PT" group-title="Portugal",RTP África +https://streaming-live.rtp.pt/liverepeater/smil:rtpafrica.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="RTP Mundo" tvg-logo="https://i.imgur.com/mJ3gyqx.png" tvg-id="RTPInternacional.pt" tvg-chno="9" tvg-country="PT" group-title="Portugal",RTP Mundo +https://streaming-live.rtp.pt/liverepeater/smil:rtpi.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="ADtv Ⓢ" tvg-logo="https://i.imgur.com/FvlcU3z.png" tvg-id="" tvg-chno="2" tvg-country="PT" group-title="Portugal",ADtv Ⓢ +https://playout172.livextend.cloud/liveiframe/_definst_/ngrp:liveartvabr_abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Qatar Television" tvg-logo="https://i.imgur.com/N5RB4sp.png" tvg-id="QatarTelevision.qa" tvg-chno="1" tvg-country="QA" group-title="Qatar",Qatar Television +https://qatartv.akamaized.net/hls/live/20000609/qtv1/master.m3u8 +#EXTINF:-1 tvg-name="Qatar Television 2" tvg-logo="https://i.imgur.com/iWJxDUm.png" tvg-id="QatarTelevision2.qa" tvg-chno="2" tvg-country="QA" group-title="Qatar",Qatar Television 2 +https://qatartv.akamaized.net/hls/live/20000611/qtv2/master.m3u8 +#EXTINF:-1 tvg-name="Qatar Television The Holy Quran" tvg-logo="https://i.imgur.com/N5RB4sp.png" tvg-id="QatarTelevisionTheHolyQuran.qa" tvg-chno="3" tvg-country="QA" group-title="Qatar",Qatar Television The Holy Quran +https://qatartv.akamaized.net/hls/live/20000612/qtvquran/master1080p.m3u8 +#EXTINF:-1 tvg-name="Al Rayyan" tvg-logo="https://i.imgur.com/Ts3RjTV.png" tvg-id="AlRayyanTV.qa" tvg-chno="4" tvg-country="QA" group-title="Qatar",Al Rayyan +https://alrayyancdn.vidgyor.com/pub-noalrayy3pwz0l/liveabr/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Al Rayyan Old TV" tvg-logo="https://i.imgur.com/4qB5iN0.png" tvg-id="AlRayyanOldTV.qa" tvg-chno="5" tvg-country="QA" group-title="Qatar",Al Rayyan Old TV +https://alrayyancdn.vidgyor.com/pub-nooldraybinbdh/liveabr/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="Al Jazeera Mubasher" tvg-logo="https://upload.wikimedia.org/wikipedia/en/9/90/Al_Jazeera_Mubasher_logo.png" tvg-id="AlJazeeraMubasher.qa" tvg-chno="6" tvg-country="QA" group-title="Qatar",Al Jazeera Mubasher +https://live-hls-web-ajm.getaj.net/AJM/index.m3u8 +#EXTINF:-1 tvg-name="Al Jazeera 2" tvg-logo="https://i.imgur.com/BB93NQP.png" tvg-id="AlJazeeraChannel2.qa" tvg-chno="7" tvg-country="QA" group-title="Qatar",Al Jazeera 2 +https://live-hls-web-aja2-gcp.thehlive.com/AJA2/index.m3u8 +#EXTINF:-1 tvg-name="Al Araby TV" tvg-logo="https://i.imgur.com/YMqWEe4.png" tvg-id="AlArabyTV.qa" tvg-chno="12" tvg-country="QA" group-title="Qatar",Al Araby TV +https://live.kwikmotion.com/alaraby1live/alaraby_abr/playlist.m3u8 +#EXTINF:-1 tvg-name="Pro TV" tvg-logo="https://i.imgur.com/aKAfKtW.png" tvg-id="ProTV.ro" tvg-chno="6" tvg-country="RO" group-title="Romania",Pro TV +https://cmero-ott-live.ssl.cdn.cra.cz/channels/cme-ro-voyo-news/playlist.m3u8?offsetSeconds=0&url=0 +#EXTINF:-1 tvg-name="Первый канал" tvg-logo="https://i.imgur.com/1IqCGe9.png" tvg-id="ChannelOne.ru" tvg-chno="1" tvg-country="RU" group-title="Russia",Первый канал +https://edge1.1internet.tv/dash-live2/streams/1tv-dvr/1tvdash.mpd +#EXTINF:-1 tvg-name="Карусель Ⓢ" tvg-logo="https://i.imgur.com/4fFMlVq.png" tvg-id="Karusel.ru" tvg-chno="8" tvg-country="RU" group-title="Russia",Карусель Ⓢ +https://streaming102.interskytech.com/live/232.m3u8 +#EXTINF:-1 tvg-name="Звезда" tvg-logo="https://i.imgur.com/c0L0ncA.png" tvg-id="TelekanalZvezda.ru" tvg-chno="17" tvg-country="RU" group-title="Russia",Звезда +https://tvchannelstream1.tvzvezda.ru/cdn/tvzvezda/playlist.m3u8 +#EXTINF:-1 tvg-name="Мир" tvg-logo="https://i.imgur.com/L2slsbG.png" tvg-id="Mir.ru" tvg-chno="18" tvg-country="RU" group-title="Russia",Мир +http://hls.mirtv.cdnvideo.ru/mirtv-parampublish/mirtv_2500/playlist.m3u8 +#EXTINF:-1 tvg-name="Муз-ТВ Ⓢ" tvg-logo="https://i.imgur.com/Ml3qqOF.png" tvg-id="MuzTV.ru" tvg-chno="20" tvg-country="RU" group-title="Russia",Муз-ТВ Ⓢ +https://streaming102.interskytech.com/live/618.m3u8 +#EXTINF:-1 tvg-name="РБК" tvg-logo="https://i.imgur.com/P2Qii5B.png" tvg-id="RBKTV.ru" tvg-chno="21" tvg-country="RU" group-title="Russia",РБК +http://92.50.128.180/utv/1358/index.m3u8 +#EXTINF:-1 tvg-name="Архыз 24" tvg-logo="https://i.imgur.com/mve0sSS.png" tvg-id="Arkhyz24.ru" tvg-country="RU" group-title="Russia",Архыз 24 +https://live.mediacdn.ru/sr1/arhis24/playlist_hdhigh.m3u8 +#EXTINF:-1 tvg-name="Астрахан 24" tvg-logo="https://i.imgur.com/9WcnjQN.png" tvg-id="Astrakhan24.ru" tvg-country="RU" group-title="Russia",Астрахан 24 +https://streaming.astrakhan.ru/astrakhan24/playlist.m3u8 +#EXTINF:-1 tvg-name="Белгород 24" tvg-logo="https://i.imgur.com/EEirvyx.png" tvg-id="Belgorod24.ru" tvg-country="RU" group-title="Russia",Белгород 24 +http://belnovosti.cdn.easyhoster.ru:8080/stream.m3u8 +#EXTINF:-1 tvg-name="Ветта 24" tvg-logo="https://i.imgur.com/zKH1b5k.png" tvg-id="Vetta24.ru" tvg-country="RU" group-title="Russia",Ветта 24 +http://serv24.vintera.tv:8081/vetta/vetta_office/playlist.m3u8 +#EXTINF:-1 tvg-name="Волгоград 24 Ⓢ" tvg-logo="https://i.imgur.com/gFMnaU5.png" tvg-id="Volgograd24.ru" tvg-country="RU" group-title="Russia",Волгоград 24 Ⓢ +https://vgtrkregion-reg.cdnvideo.ru/vgtrk/volgograd/russia1-hd/index.m3u8 +#EXTINF:-1 tvg-name="Запад 24" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/f/f8/Zapad_24.jpg" tvg-id="Zapad24.ru" tvg-country="RU" group-title="Russia",Запад 24 +https://vgtrkregion-reg.cdnvideo.ru/vgtrk/kaliningrad/russia1-hd/index.m3u8 +#EXTINF:-1 tvg-name="Крым 24" tvg-logo="https://i.imgur.com/k4C0uvp.png" tvg-id="Crimea24.ru" tvg-country="RU" group-title="Russia",Крым 24 +https://cdn.1tvcrimea.ru/24tvcrimea.m3u8 +#EXTINF:-1 tvg-name="Мир Белогорья" tvg-logo="https://i.imgur.com/CCNAg7R.png" tvg-id="MirBelogorya.ru" tvg-country="RU" group-title="Russia",Мир Белогорья +http://mirbelogorya.ru:8080/mirbelogorya/index.m3u8 +#EXTINF:-1 tvg-name="Нижний Новгород 24" tvg-logo="https://i.imgur.com/ZWgPVIC.png" tvg-id="NizhniyNovgorod24.ru" tvg-country="RU" group-title="Russia",Нижний Новгород 24 +https://live-vestinn.cdnvideo.ru/vestinn/nn24-khl/playlist.m3u8 +#EXTINF:-1 tvg-name="Сибирь 24" tvg-logo="https://i.imgur.com/WxU6QUB.png" tvg-id="Sibir24.ru" tvg-country="RU" group-title="Russia",Сибирь 24 +https://vgtrkregion-reg.cdnvideo.ru/vgtrk/novosibirsk/russia1-hd/index.m3u8 +#EXTINF:-1 tvg-name="Якутия 24" tvg-logo="https://i.imgur.com/2BAQklm.png" tvg-id="Yakutiya24.ru" tvg-country="RU" group-title="Russia",Якутия 24 +https://live-saha.cdnvideo.ru/saha2/yak24rtmp_live.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="360 Новости" tvg-logo="https://i.imgur.com/YXDeX8q.png" tvg-id="360News.ru" tvg-country="RU" group-title="Russia",360 Новости +https://live-vgtrksmotrim.cdnvideo.ru/vgtrksmotrim/smotrim-live-03-srt.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Север" tvg-logo="https://i.imgur.com/sTOQLYl.png" tvg-id="Sever.ru" tvg-country="RU" group-title="Russia",Север +https://live.mediacdn.ru/sr1/sever/playlist.m3u8 +#EXTINF:-1 tvg-name="Смотрим: Честный Детектив" tvg-logo="https://cdn-st3.smotrim.ru/vh/pictures/r/444/241/8.png" tvg-country="RU" group-title="Russia",Смотрим: Честный Детектив +https://live-vgtrksmotrim.cdnvideo.ru/vgtrksmotrim/smotrim-live-01.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Al Saudiya" tvg-logo="https://i.imgur.com/GRQTndk.png" tvg-id="AlSaudiya.sa" tvg-chno="1" tvg-country="SA" group-title="Saudi Arabia",Al Saudiya +https://shd-gcp-live.edgenextcdn.net/live/bitmovin-saudi-tv/2ad66056b51fd8c1b624854623112e43/index.m3u8 +#EXTINF:-1 tvg-name="SBC Saudi Arabia" tvg-logo="https://i.imgur.com/9JSQglj.png" tvg-id="SBC.sa" tvg-chno="2" tvg-country="SA" group-title="Saudi Arabia",SBC Saudi Arabia +https://shd-gcp-live.edgenextcdn.net/live/bitmovin-sbc/90e09c0c28db26435799b4a14892a167/index.m3u8 +#EXTINF:-1 tvg-name="Al Ekhbariya" tvg-logo="https://i.imgur.com/WcRlHQm.png" tvg-id="AlEkhbariya.sa" tvg-chno="4" tvg-country="SA" group-title="Saudi Arabia",Al Ekhbariya +https://shd-gcp-live.edgenextcdn.net/live/bitmovin-al-ekhbaria/297b3ef1cd0633ad9cfba7473a686a06/index.m3u8 +#EXTINF:-1 tvg-name="Al Saudiya Alaan" tvg-logo="https://i.imgur.com/sEOjApe.png" tvg-id="AlSaudiyaAlaan.sa" tvg-chno="5" tvg-country="SA" group-title="Saudi Arabia",Al Saudiya Alaan +https://shd-gcp-live.edgenextcdn.net/live/bitmovin-ksa-now/71ed3aa814c643306c0a8bc4fcc7d17f/index.m3u8 +#EXTINF:-1 tvg-name="Red TV" tvg-logo="https://i.imgur.com/cpN7NrL.png" tvg-id="RedTV.rs" tvg-chno="4" tvg-country="RS" group-title="Serbia",Red TV +https://edge8.pink.rs/redtv/index.m3u8 +#EXTINF:-1 tvg-name="Kurir TV" tvg-logo="https://i.imgur.com/HBPnUD5.png" tvg-id="KurirTV.rs" tvg-chno="5" tvg-country="RS" group-title="Serbia",Kurir TV +https://static.am.mediaoutcast.com/storage/nQJnjJkO/nQJnjJkO/stream/O68x4o8g/720p/720p.m3u8 +#EXTINF:-1 tvg-name="Senzi" tvg-logo="https://i.imgur.com/W82dwzf.png" tvg-id="Senzi.sk" tvg-chno="13" tvg-country="SK" group-title="Slovakia",Senzi +http://lb.streaming.sk/senzi/stream/playlist.m3u8 +#EXTINF:-1 tvg-name="euronews" tvg-logo="https://raw.githubusercontent.com/tv-logo/tv-logos/refs/heads/main/countries/international/euro-news-int.png" tvg-id="Euronews.es" tvg-chno="22" tvg-country="ES" group-title="Spain",euronews +https://euronews-live-spa-es.fast.rakuten.tv/v1/master/0547f18649bd788bec7b67b746e47670f558b6b2/production-LiveChannel-6571/bitok/eyJzdGlkIjoiMDA0YjY0NTMtYjY2MC00ZTZkLTlkNzEtMTk3YTM3ZDZhZWIxIiwibWt0IjoiZXMiLCJjaCI6NjU3MSwicHRmIjoxfQ==/26034/euronews-es.m3u8 +#EXTINF:-1 tvg-name="Negocios" tvg-logo="https://pbs.twimg.com/profile_images/1321367703731523584/bNMmbetI_200x200.jpg" tvg-id="NegociosTV.es" tvg-chno="24" tvg-country="ES" group-title="Spain",Negocios +https://streaming013.gestec-video.com/hls/negociostv.m3u8 +#EXTINF:-1 tvg-name="ETB1" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/ETB1_2022_logo.svg/512px-ETB1_2022_logo.svg.png" tvg-id="ETB1.es" tvg-chno="10" tvg-country="ES" group-title="Spain",ETB1 +https://multimedia.eitb.eus/live-content/etb1hd-hls/master.m3u8 +#EXTINF:-1 tvg-name="ETB2" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c4/ETB2_2022_logo.svg/512px-ETB2_2022_logo.svg.png" tvg-id="ETB2.es" tvg-chno="11" tvg-country="ES" group-title="Spain",ETB2 +https://multimedia.eitb.eus/live-content/etb2hd-hls/master.m3u8 +#EXTINF:-1 tvg-name="TV3Cat Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/TV3CAT.svg/512px-TV3CAT.svg.png" tvg-id="TV3CAT.es" tvg-chno="12" tvg-country="ES" group-title="Spain",TV3Cat Ⓖ +https://directes3-tv-int.3catdirectes.cat/live-content/tvi-hls/master.m3u8 +#EXTINF:-1 tvg-name="3/24" tvg-logo="https://raw.githubusercontent.com/tv-logo/tv-logos/refs/heads/main/countries/spain/3-24-es.png" tvg-id="324.es" tvg-chno="13" tvg-country="ES" group-title="Spain",3/24 +https://directes-tv-int.3catdirectes.cat/live-origin/canal324-hls/master.m3u8 +#EXTINF:-1 tvg-name="Bon Dia" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/4/4f/Logo_Bon_Dia_TV.png" tvg-id="BonDiaTV.es" tvg-chno="14" tvg-country="ES" group-title="Spain",Bon Dia +https://directes-tv-int.3catdirectes.cat/live-content/bondia-hls/master.m3u8 +#EXTINF:-1 tvg-name="La 2 (Canarias)" tvg-logo="https://i.imgur.com/DmuTwDw.png" tvg-id="La2Canarias.es" tvg-chno="21" tvg-country="ES" group-title="Spain",La 2 (Canarias) +https://ztnr.rtve.es/ztnr/5468585.m3u8 +#EXTINF:-1 tvg-name="Expressen TV" tvg-logo="https://i.imgur.com/8EjMSr7.png" tvg-id="ExpressenTV.se" tvg-chno="43" tvg-country="SE" group-title="Sweden",Expressen TV +https://cdn0-03837-liveedge0.dna.ip-only.net/03837-liveedge0/smil:03837-tx2/playlist.m3u8 +#EXTINF:-1 tvg-name="Di TV" tvg-logo="https://i.imgur.com/zApTDWn.png" tvg-id="DiTV.se" tvg-chno="121" tvg-country="SE" group-title="Sweden",Di TV +https://cdn0-03837-liveedge0.dna.ip-only.net/03837-liveedge0/smil:03837-tx4/playlist.m3u8 +#EXTINF:-1 tvg-name="Sundskanalen" tvg-logo="https://i.imgur.com/8uT0p3q.jpg" tvg-id="Sundskanalen.se" tvg-chno="9" tvg-country="SE" group-title="Sweden",Sundskanalen +https://stream.sundskanalen.se/live/view/index.m3u8 +#EXTINF:-1 tvg-name="TVM 3" tvg-logo="https://i.imgur.com/3v6iZE6.png" tvg-id="TVM3.ch" tvg-chno="3" tvg-country="CH" group-title="Switzerland",TVM 3 +http://livevideo.infomaniak.com/streaming/livecast/tvm3/playlist.m3u8 +#EXTINF:-1 tvg-name="Teleticino" tvg-logo="https://i.imgur.com/zm2ruqz.png" tvg-id="TeleTicino.ch" tvg-chno="3" tvg-country="CH" group-title="Switzerland",Teleticino +https://vstream-cdn.ch/hls/teleticino_720p/index.m3u8 +#EXTINF:-1 tvg-name="TRT Haber" tvg-logo="https://i.imgur.com/OVfo8Ab.png" tvg-id="TRTHaber.tr" tvg-chno="3" tvg-country="TR" group-title="Turkey",TRT Haber +https://tv-trthaber.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Çocuk" tvg-logo="https://i.imgur.com/QLFmD6d.png" tvg-id="TRTCocuk.tr" tvg-chno="6" tvg-country="TR" group-title="Turkey",TRT Çocuk +https://tv-trtcocuk.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Müzik" tvg-logo="https://i.imgur.com/fIVFCEd.png" tvg-chno="7" tvg-country="TR" group-title="Turkey",TRT Müzik +https://tv-trtmuzik.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Avaz" tvg-logo="https://i.imgur.com/VhTwXu5.png" tvg-id="TRTAvaz.tr" tvg-chno="9" tvg-country="TR" group-title="Turkey",TRT Avaz +https://tv-trtavaz.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Kurdî" tvg-logo="https://i.imgur.com/6BpymfB.png" tvg-id="TRTKurdi.tr" tvg-chno="10" tvg-country="TR" group-title="Turkey",TRT Kurdî +https://tv-trtkurdi.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Arabi" tvg-logo="https://i.imgur.com/yyhWOZs.png" tvg-id="TRTArabi.tr" tvg-chno="11" tvg-country="TR" group-title="Turkey",TRT Arabi +https://tv-trtarabi.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT World" tvg-logo="https://i.imgur.com/JEA2xpv.png" tvg-id="TRTWorld.tr" tvg-chno="12" tvg-country="TR" group-title="Turkey",TRT World +https://tv-trtworld.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT Türk" tvg-logo="https://i.imgur.com/OSTOQNw.png" tvg-id="TRTTurk.tr" tvg-chno="13" tvg-country="TR" group-title="Turkey",TRT Türk +https://tv-trtturk.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT EBA Ilkokul" tvg-logo="https://i.imgur.com/wDvZfk8.png" tvg-chno="14" tvg-country="TR" group-title="Turkey",TRT EBA Ilkokul +https://tv-e-okul00.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT EBA Ortaokul" tvg-logo="https://i.imgur.com/yfPTvRx.png" tvg-chno="15" tvg-country="TR" group-title="Turkey",TRT EBA Ortaokul +https://tv-e-okul01.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="TRT EBA Lise" tvg-logo="https://i.imgur.com/IebUZx1.png" tvg-chno="16" tvg-country="TR" group-title="Turkey",TRT EBA Lise +https://tv-e-okul02.medya.trt.com.tr/master.m3u8 +#EXTINF:-1 tvg-name="S4C Ⓖ" tvg-logo="https://i.imgur.com/vrcbnBv.png" tvg-id="S4C.uk" tvg-chno="7" tvg-country="GB" group-title="UK",S4C Ⓖ +https://live-uk.s4c-cdn.co.uk/out/v1/a0134f1fd5a2461b9422b574566d4442/live_uk.m3u8 +#EXTINF:-1 tvg-name="QVC UK Ⓢ" tvg-logo="https://i.imgur.com/6TWUVrh.png" tvg-id="QVCUK.uk" tvg-chno="18" tvg-country="GB" group-title="UK",QVC UK Ⓢ +https://qvcuk-live.akamaized.net/hls/live/2097112/qvc/3/3.m3u8 +#EXTINF:-1 tvg-name="TJC" tvg-logo="https://i.imgur.com/fk5rEje.png" tvg-id="TJC.uk" tvg-chno="22" tvg-country="GB" group-title="UK",TJC +https://cdn-shop-lc-01.akamaized.net/Content/HLS_HLS/Live/channel(TJCOTT)/index.m3u8 +#EXTINF:-1 tvg-name="GB News" tvg-logo="https://upload.wikimedia.org/wikipedia/en/thumb/3/35/GB_News_Logo.svg/640px-GB_News_Logo.svg.png" tvg-id="GBNews.uk" tvg-chno="236" tvg-country="GB" group-title="UK",GB News +https://live-gbnews.simplestreamcdn.com/live5/gbnews/bitrate1.isml/manifest.m3u8 +#EXTINF:-1 tvg-name="Bloomberg TV" tvg-logo="https://d2n0069hmnqmmx.cloudfront.net/epgdata/1.0/newchanlogos/512/512/skychb1074.png" tvg-id="BloombergTVEurope.uk" tvg-chno="208" tvg-country="GB" group-title="UK",Bloomberg TV +https://bloomberg.com/media-manifest/streams/eu.m3u8 +#EXTINF:-1 tvg-name="QVC Beauty Ⓢ" tvg-logo="https://i.imgur.com/ZBHtqk1.png" tvg-id="QVCBeautyUK.uk" tvg-chno="801" tvg-country="GB" group-title="UK",QVC Beauty Ⓢ +https://qvcuk-live.akamaized.net/hls/live/2097112/qby/3/3.m3u8 +#EXTINF:-1 tvg-name="QVC Extra Ⓢ" tvg-logo="https://i.imgur.com/TIe5T9Z.png" tvg-id="QVCExtraUK.uk" tvg-chno="802" tvg-country="GB" group-title="UK",QVC Extra Ⓢ +https://qvcuk-live.akamaized.net/hls/live/2097112/qex/3/3.m3u8 +#EXTINF:-1 tvg-name="QVC Style Ⓢ" tvg-logo="https://i.imgur.com/6HZlLL3.png" tvg-id="QVCStyleUK.uk" tvg-chno="803" tvg-country="GB" group-title="UK",QVC Style Ⓢ +https://qvcuk-live.akamaized.net/hls/live/2097112/qst/3/3.m3u8 +#EXTINF:-1 tvg-name="Now 70s" tvg-logo="https://i.imgur.com/qiCCX5X.png" tvg-id="Now70s.uk" tvg-chno="361" tvg-country="GB" group-title="UK",Now 70s +https://lightning-now70s-samsungnz.amagi.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="Now 80s" tvg-logo="https://i.imgur.com/8paz37m.png" tvg-id="Now80s.uk" tvg-chno="362" tvg-country="GB" group-title="UK",Now 80s +https://lightning-now80s-samsunguk.amagi.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="Now Rock" tvg-logo="https://upload.wikimedia.org/wikipedia/en/8/89/NOW_Rock_logo.png" tvg-id="NowRock.uk" tvg-chno="363" tvg-country="GB" group-title="UK",Now Rock +https://lightning-now90s-samsungnz.amagi.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="Suspilne Sport" tvg-logo="https://i.imgur.com/16IhU0M.png" tvg-id="SuspilneSport.ua" tvg-chno="5" tvg-country="UA" group-title="Ukraine",Suspilne Sport +http://cdnua05.hls.tv/934/hls/8743361621b245838bee193c9ec28322/4856/stream.m3u8 +#EXTINF:-1 tvg-name="ICTV2" tvg-logo="https://i.imgur.com/J4zlRGv.png" tvg-id="ICTV2.ua" tvg-chno="6" tvg-country="UA" group-title="Ukraine",ICTV2 +http://cdnua05.hls.tv/919/hls/8743361621b245838bee193c9ec28322/4835/stream.m3u8 +#EXTINF:-1 tvg-name="ICTV Serialy" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/7/7a/ICTV_Serialy_%282024%29.png" tvg-id="ICTVSerialy.ua" tvg-chno="7" tvg-country="UA" group-title="Ukraine",ICTV Serialy +http://cdnua05.hls.tv/624/hls/f62d71219200da4130d13b21d23fb23c/4896/stream.m3u8 +#EXTINF:-1 tvg-name="XSport Ⓢ" tvg-logo="https://i.imgur.com/CHDcfrT.png" tvg-id="XSport.ua" tvg-chno="23" tvg-country="UA" group-title="Ukraine",XSport Ⓢ +http://cdnua05.hls.tv/946/hls/8743361621b245838bee193c9ec28322/3999/stream.m3u8 +#EXTINF:-1 tvg-name="5 Kanal" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Logo_5_Channel.svg/480px-Logo_5_Channel.svg.png" tvg-id="5Kanal.ua" tvg-chno="26" tvg-country="UA" group-title="Ukraine",5 Kanal +https://776067.live.tvstitch.com/stream.m3u8?m=aHR0cHM6Ly9jZG4uZHl2eWFwcC5jb20vNS1jaGFubmVsL3ZpZGVvLm0zdTg%2FdG9rZW49ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjQ0k2SWpKaE1EWTZOVGt3TWpveE0yTXdPakE2WlRobFlqbzJNRGM0T2pSa01qcG1OV01pTENKMWMyVnlYMkZuWlc1MElqb2lUVzk2YVd4c1lWd3ZOUzR3SUNoWGFXNWtiM2R6SUU1VUlERXdMakE3SUZkcGJqWTBPeUI0TmpRN0lISjJPakV6Tmk0d0tTQkhaV05yYjF3dk1qQXhNREF4TURFZ1JtbHlaV1p2ZUZ3dk1UTTJMakFpTENKbGVIQWlPakUzTkRFNE1UUTVPVEI5Lkllb2JDR0NtVDVOd1pKSjdzNEhLaVIzb01IZVhpQ2dnZVp5WjZ6YWxodGM%3D&channel=1&u=b048004d-9363-4406-9b23-cb12b731d6e4&gdpr_consent=%7Bgdpr_consent%7D&givn=%7Bgivn%7D +#EXTINF:-1 tvg-name="Суспільне Дніпро" tvg-logo="https://i.imgur.com/nMHd9FK.png" tvg-id="SuspilneDnipro.ua" tvg-chno="2" tvg-country="UA" group-title="Ukraine",Суспільне Дніпро +http://cdnua05.hls.tv/648/hls/f62d71219200da4130d13b21d23fb23c/4786/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Житомир Ⓢ" tvg-logo="https://i.imgur.com/lCv1Xaq.png" tvg-id="SuspilneZhytomyr.ua" tvg-chno="3" tvg-country="UA" group-title="Ukraine",Суспільне Житомир Ⓢ +http://cdnua05.hls.tv/647/hls/f62d71219200da4130d13b21d23fb23c/4787/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Одеса Ⓢ" tvg-logo="https://i.imgur.com/giTdUK9.png" tvg-id="SuspilneOdesa.ua" tvg-chno="4" tvg-country="UA" group-title="Ukraine",Суспільне Одеса Ⓢ +http://cdnua05.hls.tv/653/hls/f62d71219200da4130d13b21d23fb23c/4833/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Суми Ⓢ" tvg-logo="https://i.imgur.com/U5GQiUz.png" tvg-id="SuspilneSumy.ua" tvg-chno="5" tvg-country="UA" group-title="Ukraine",Суспільне Суми Ⓢ +http://cdnua05.hls.tv/630/hls/f62d71219200da4130d13b21d23fb23c/4798/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Хмельницький Ⓢ" tvg-logo="https://i.imgur.com/uoTcTbU.png" tvg-id="SuspilneKhmelnytskyi.ua" tvg-chno="6" tvg-country="UA" group-title="Ukraine",Суспільне Хмельницький Ⓢ +http://cdnua05.hls.tv/632/hls/f62d71219200da4130d13b21d23fb23c/4800/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Чернівці Ⓢ" tvg-logo="https://i.imgur.com/mzYRGp2.png" tvg-id="SuspilneChernivtsi.ua" tvg-chno="7" tvg-country="UA" group-title="Ukraine",Суспільне Чернівці Ⓢ +http://cdnua05.hls.tv/643/hls/f62d71219200da4130d13b21d23fb23c/4790/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Kropyvnytskyi" tvg-logo="https://i.imgur.com/zzYJr87.png" tvg-id="SuspilneKropyvnytskyi.ua" tvg-chno="8" tvg-country="UA" group-title="Ukraine",Суспільне Kropyvnytskyi +http://cdnua05.hls.tv/650/hls/f62d71219200da4130d13b21d23fb23c/4788/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Тернопіль" tvg-logo="https://i.imgur.com/rxXyCY7.png" tvg-id="SuspilneTernopil.ua" tvg-chno="11" tvg-country="UA" group-title="Ukraine",Суспільне Тернопіль +http://cdnua05.hls.tv/647/hls/f62d71219200da4130d13b21d23fb23c/4789/stream.m3u8 +#EXTINF:-1 tvg-name="Суспільне Чернігів" tvg-logo="https://i.imgur.com/WBChVXj.png" tvg-id="SuspilneChernihiv.ua" tvg-chno="12" tvg-country="UA" group-title="Ukraine",Суспільне Чернігів +http://cdnua05.hls.tv/645/hls/f62d71219200da4130d13b21d23fb23c/4791/stream.m3u8 +#EXTINF:-1 tvg-name="24 Kanal" tvg-logo="https://pbs.twimg.com/profile_images/1498285886714298374/EMSJzC-0_400x400.jpg" tvg-id="24Kanal.ua" tvg-chno="2" tvg-country="UA" group-title="Ukraine",24 Kanal +http://streamvideol1.luxnet.ua/news24/smil:news24.stream.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Telekanal RAI" tvg-logo="https://i.imgur.com/Ouv51WB.png" tvg-id="TelekanalRAI.ua" tvg-chno="6" tvg-country="UA" group-title="Ukraine",Telekanal RAI +https://stream.rai.ua/rai/stream.m3u8 +#EXTINF:-1 tvg-name="Pervyy gorodskoy Odessa" tvg-logo="https://i.imgur.com/8qdc6aO.png" tvg-id="PervyygorodskoyOdessa.ua" tvg-chno="9" tvg-country="UA" group-title="Ukraine",Pervyy gorodskoy Odessa +https://live.1tv.od.ua/stream1/channel1/playlist.m3u8 +#EXTINF:-1 tvg-name="NTK TV" tvg-logo="https://www.ntktv.ua/bitrix/templates/ntk_copy/images/logo.png" tvg-id="NTKTV.ua" tvg-chno="12" tvg-country="UA" group-title="Ukraine",NTK TV +http://stream.ntktv.ua/s/ntk/ntk.m3u8 +#EXTINF:-1 tvg-name="TVA" tvg-logo="https://i.imgur.com/PtTQ5lv.png" tvg-id="TVA.ua" tvg-chno="13" tvg-country="UA" group-title="Ukraine",TVA +https://hls.cdn.ua/tva.ua_live/livestream/chunklist_.m3u8 +#EXTINF:-1 tvg-name="Al Arabiya Business" tvg-logo="https://i.imgur.com/eEV4r6J.jpg" tvg-id="AlArabiyaBusiness.ae" tvg-chno="2" tvg-country="AE" group-title="United Arab Emirates",Al Arabiya Business +https://live.alarabiya.net/alarabiapublish/aswaaq.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Sky News Arabia" tvg-logo="https://i.imgur.com/SvjU4h6.png" tvg-id="SkyNewsArabia.ae" tvg-chno="14" tvg-country="AE" group-title="United Arab Emirates",Sky News Arabia +https://stream.skynewsarabia.com/hls/sna.m3u8 +#EXTINF:-1 tvg-name="Al Wousta" tvg-logo="https://i5.satexpat.com/cha/ae/al-wousta-95x90.gif" tvg-id="SharjahSports.ae" tvg-chno="3" tvg-country="AE" group-title="United Arab Emirates",Al Wousta +https://svs.itworkscdn.net/alwoustalive/alwoustatv.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Bloomberg" tvg-logo="https://i.imgur.com/VnCcH73.png" tvg-id="BloombergTelevision.us" tvg-chno="3" tvg-country="US" group-title="USA",Bloomberg +https://bloomberg.com/media-manifest/streams/us.m3u8 +#EXTINF:-1 tvg-name="CBS News" tvg-logo="https://i.imgur.com/nki2HDQ.png" tvg-id="CBSNews.us" tvg-chno="5" tvg-country="US" group-title="USA",CBS News +https://cbsnews.akamaized.net/hls/live/2020607/cbsnlineup_8/master.m3u8 +#EXTINF:-1 tvg-name="BBC Food" tvg-logo="https://i.imgur.com/N3xiz4m.png" tvg-id="BBCFood.us" tvg-chno="1" tvg-country="US" group-title="USA",BBC Food +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/5fb5844bf5514d0007945bda/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="BBC Home" tvg-logo="https://i.imgur.com/Ii8DX1x.png" tvg-id="BBCHome.us" tvg-chno="2" tvg-country="US" group-title="USA",BBC Home +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/5fb5836fe745b600070fc743/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Music Channel" tvg-logo="http://media.boni-records.com/logo.png" tvg-id="Boni-Records.com.us" tvg-chno="3" tvg-country="US" group-title="USA",Music Channel +http://media.boni-records.com/index.m3u8 +#EXTINF:-1 tvg-name="Telesur" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/8/82/TeleSUR.png" tvg-id="TeleSUR.ve" tvg-chno="8" tvg-country="VE" group-title="Venezuela",Telesur +https://raw.githubusercontent.com/BellezaEmporium/IPTV_Exception/master/channels/ve/telesur.m3u8 +#EXTINF:-1 tvg-name="Latina TV" tvg-logo="https://intervenhosting.net/imagenes/latinatv.jpg" tvg-id="LatinaTV.ve" tvg-chno="10" tvg-country="VE" group-title="Venezuela",Latina TV +https://streamtv.latinamedios.com:3413/live/latinatvlive.m3u8 +#EXTINF:-1 tvg-name="Al Jazeera Documentary Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/en/e/e6/Al_Jazeera_Doc.png" tvg-id="AlJazeeraDocumentary.qa" group-title="Documentaries (AR)",Al Jazeera Documentary Ⓖ +https://live-hls-web-ajd.getaj.net/AJD/index.m3u8 +#EXTINF:-1 tvg-name="RT Documentary English Ⓖ" tvg-logo="https://i.imgur.com/ZEi1Wgn.png" tvg-id="RTDoc.ru" group-title="Documentaries (EN)",RT Documentary English Ⓖ +https://rt-rtd.rttv.com/dvr/rtdoc/playlist.m3u8 +#EXTINF:-1 tvg-name="Peer TV South Tyrol" tvg-logo="https://www.peer.biz/peertv-iptv/peer-tv-south-tyrol.png" tvg-id="PeerTV.it" group-title="Documentaries (EN)",Peer TV South Tyrol +https://iptv.peer.biz/live/peertv-en.m3u8 +#EXTINF:-1 tvg-name="Pluto TV Spotlight" tvg-logo="https://i.imgur.com/AogTmZc.png" group-title="VOD Movies (EN)",Pluto TV Spotlight +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5ba3fb9c4b078e0f37ad34e8/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=51&deviceId=5ba3fb9c4b078e0f37ad34e8&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Action" tvg-logo="https://i.imgur.com/g8PCdh6.png" group-title="VOD Movies (EN)",Pluto TV Action +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/561d7d484dc7c8770484914a/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=54&deviceId=561d7d484dc7c8770484914a&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Comedy" tvg-logo="https://i.imgur.com/Pjs4lgs.png" group-title="VOD Movies (EN)",Pluto TV Comedy +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5a4d3a00ad95e4718ae8d8db/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=57&deviceId=5a4d3a00ad95e4718ae8d8db&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Drama" tvg-logo="https://i.imgur.com/B9srooj.png" group-title="VOD Movies (EN)",Pluto TV Drama +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5b4e92e4694c027be6ecece1/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=60&deviceId=5b4e92e4694c027be6ecece1&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Fantastic" tvg-logo="https://i.imgur.com/dOfXc5w.png" group-title="VOD Movies (EN)",Pluto TV Fantastic +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5b64a245a202b3337f09e51d/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=66&deviceId=5b64a245a202b3337f09e51d&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Romance" tvg-logo="https://i.imgur.com/j6livg0.png" group-title="VOD Movies (EN)",Pluto TV Romance +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5a66795ef91fef2c7031c599/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=70&deviceId=5a66795ef91fef2c7031c599&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Crime Movies" tvg-logo="https://i.imgur.com/PlAQrIb.png" group-title="VOD Movies (EN)",Pluto TV Crime Movies +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5f4d8594eb979c0007706de7/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=73&deviceId=5f4d8594eb979c0007706de7&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Thrillers" tvg-logo="https://i.imgur.com/jyiFzG4.png" group-title="VOD Movies (EN)",Pluto TV Thrillers +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5b4e69e08291147bd04a9fd7/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=74&deviceId=5b4e69e08291147bd04a9fd7&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Horror" tvg-logo="https://i.imgur.com/An93hAh.png" group-title="VOD Movies (EN)",Pluto TV Horror +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/569546031a619b8f07ce6e25/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=75&deviceId=569546031a619b8f07ce6e25&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Terror" tvg-logo="https://i.imgur.com/JLgn5jC.png" group-title="VOD Movies (EN)",Pluto TV Terror +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5c6dc88fcd232425a6e0f06e/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=76&deviceId=5c6dc88fcd232425a6e0f06e&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Black Cinema" tvg-logo="https://i.imgur.com/Zh1QGW9.png" group-title="VOD Movies (EN)",Black Cinema +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/58af4c093a41ca9d4ecabe96/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=80&deviceId=58af4c093a41ca9d4ecabe96&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Staff Picks" tvg-logo="https://i.imgur.com/DFDHAT8.png" group-title="VOD Movies (EN)",Pluto TV Staff Picks +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5f4d863b98b41000076cd061/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=90&deviceId=5f4d863b98b41000076cd061&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Documentaries" tvg-logo="https://i.imgur.com/Mr4ZsNZ.png" group-title="VOD Movies (EN)",Pluto TV Documentaries +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5b85a7582921777994caea63/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=91&deviceId=5b85a7582921777994caea63&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="90s Throwback" tvg-logo="https://i.imgur.com/sI1o3uK.png" group-title="VOD Movies (EN)",90s Throwback +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5f4d86f519358a00072b978e/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=94&deviceId=5f4d86f519358a00072b978e&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="80s Rewind" tvg-logo="https://i.imgur.com/0FaLAhK.png" group-title="VOD Movies (EN)",80s Rewind +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5ca525b650be2571e3943c63/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=95&deviceId=5ca525b650be2571e3943c63&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="70s Cinema" tvg-logo="https://i.imgur.com/wk9Baz9.png" group-title="VOD Movies (EN)",70s Cinema +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5f4d878d3d19b30007d2e782/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=96&deviceId=5f4d878d3d19b30007d2e782&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Paramount Movie Channel" tvg-logo="https://i.imgur.com/CfqRav0.png" group-title="VOD Movies (EN)",Paramount Movie Channel +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5cb0cae7a461406ffe3f5213/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=100&deviceId=5cb0cae7a461406ffe3f5213&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Westerns" tvg-logo="https://i.imgur.com/79R7m0b.png" group-title="VOD Movies (EN)",Pluto TV Westerns +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5b4e94282d4ec87bdcbb87cd/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=103&deviceId=5b4e94282d4ec87bdcbb87cd&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Classic Movies" tvg-logo="https://i.imgur.com/feWPHep.png" group-title="VOD Movies (EN)",Classic Movies +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/561c5b0dada51f8004c4d855/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=106&deviceId=561c5b0dada51f8004c4d855&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Pluto TV Cult Films" tvg-logo="https://i.imgur.com/kD3SkoC.png" group-title="VOD Movies (EN)",Pluto TV Cult Films +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/5c665db3e6c01b72c4977bc2/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=109&deviceId=5c665db3e6c01b72c4977bc2&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Flicks of Fury" tvg-logo="https://i.imgur.com/yhyzBfb.png" group-title="VOD Movies (EN)",Flicks of Fury +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/58e55b14ad8e9c364d55f717/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=112&deviceId=58e55b14ad8e9c364d55f717&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="The Asylum" tvg-logo="https://i.imgur.com/rOxQfdG.png" group-title="VOD Movies (EN)",The Asylum +http://service-stitcher.clusters.pluto.tv/stitch/hls/channel/591105034c1806b47438342c/master.m3u8?terminate=false&deviceType=web&deviceMake=web&deviceModel=web&sid=115&deviceId=591105034c1806b47438342c&deviceVersion=DNT&appVersion=DNT&deviceDNT=0&userId=&advertisingId=&deviceLat=&deviceLon=&app_name=&appName=web&buildVersion=&appStoreUrl=&architecture=&includeExtendedEvents=false&marketingRegion=US&serverSideAds=false +#EXTINF:-1 tvg-name="Al Jazeera العربية" tvg-logo="https://i.imgur.com/BB93NQP.png" tvg-id="AlJazeeraChannel.qa" tvg-chno="1" group-title="News (AR)",Al Jazeera العربية +https://live-hls-web-aja.getaj.net/AJA/index.m3u8 +#EXTINF:-1 tvg-name="Al Arabiya العربية" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Al-Arabiya_new_logo.svg/640px-Al-Arabiya_new_logo.svg.png" tvg-id="AlArabiya.ae" tvg-chno="2" group-title="News (AR)",Al Arabiya العربية +https://live.alarabiya.net/alarabiapublish/alarabiya.smil/playlist.m3u8 +#EXTINF:-1 tvg-name="Al Hadath العربية" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/a/ae/Al_Hadath_TV_logo_2023.svg" tvg-id="AlHadath.sa" tvg-chno="3" group-title="News (AR)",Al Hadath العربية +https://live.alarabiya.net/alarabiapublish/alhadath.smil/alarabiapublish/alhadath_1080p/chunks.m3u8 +#EXTINF:-1 tvg-name="DW العربية" tvg-logo="https://i.imgur.com/A1xzjOI.png" tvg-id="DWArabic.de" tvg-chno="5" group-title="News (AR)",DW العربية +https://dwamdstream103.akamaized.net/hls/live/2015526/dwstream103/index.m3u8 +#EXTINF:-1 tvg-name="CGTN العربية" tvg-logo="https://i.imgur.com/fMsJYzl.png" tvg-id="CGTNArabic.cn" tvg-chno="6" group-title="News (AR)",CGTN العربية +https://arabic-livews.cgtn.com/hls/LSveq57bErWLinBnxosqjisZ220802LSTefTAS9zc9mpU08y3np9TH220802cd/playlist.m3u8 +#EXTINF:-1 tvg-name="Sky News العربية" tvg-logo="https://upload.wikimedia.org/wikipedia/en/thumb/5/57/Sky_News_logo.svg/512px-Sky_News_logo.svg.png" tvg-id="SkyNewsArabia.ae" tvg-chno="7" group-title="News (AR)",Sky News العربية +https://stream.skynewsarabia.com/hls/sna.m3u8 +#EXTINF:-1 tvg-name="RT العربية" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Russia-today-logo.svg/512px-Russia-today-logo.svg.png" tvg-id="RTArabic.ru" tvg-chno="8" group-title="News (AR)",RT العربية +https://rt-arb.rttv.com/dvr/rtarab/playlist.m3u8 +#EXTINF:-1 tvg-name="Sky News (UK)" tvg-logo="https://d2n0069hmnqmmx.cloudfront.net/epgdata/1.0/newchanlogos/512/512/skychb1404.png" tvg-id="SkyNewsInternational.uk" tvg-chno="1" group-title="News",Sky News (UK) +https://ythls.armelin.one/channel/UCoMdktPbSTixAyNGwb-UYkQ.m3u8 +#EXTINF:-1 tvg-name="DW" tvg-logo="https://i.imgur.com/A1xzjOI.png" tvg-id="DWEnglish.de" tvg-chno="5" group-title="News",DW +https://dwamdstream102.akamaized.net/hls/live/2015525/dwstream102/index.m3u8 +#EXTINF:-1 tvg-name="Al Jazeera" tvg-logo="https://i.imgur.com/BB93NQP.png" tvg-id="AlJazeeraEnglish.qa" tvg-chno="6" group-title="News",Al Jazeera +https://live-hls-apps-aje-fa.getaj.net/AJE/index.m3u8 +#EXTINF:-1 tvg-name="CBS News" tvg-logo="https://raw.githubusercontent.com/tv-logo/tv-logos/main/countries/united-states/cbs-news-us.png" tvg-id="CBSNews.us" tvg-chno="12" group-title="News",CBS News +https://dai.google.com/linear/hls/event/Sid4xiTQTkCT1SLu6rjUSQ/master.m3u8 +#EXTINF:-1 tvg-name="India Today" tvg-logo="https://i.imgur.com/koFYddE.png" tvg-id="IndiaToday.in" tvg-chno="17" group-title="News",India Today +https://indiatodaylive.akamaized.net/hls/live/2014320/indiatoday/indiatodaylive/playlist.m3u8 +#EXTINF:-1 tvg-name="Channel News Asia" tvg-logo="https://i.imgur.com/xWglicB.png" tvg-id="CNAInternational.sg" tvg-chno="18" group-title="News",Channel News Asia +https://ythls.armelin.one/channel/UC83jt4dlz1Gjl58fzQrrKZg.m3u8 +#EXTINF:-1 tvg-name="NHK World Japan" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/NHK_World-Japan_TV.svg/512px-NHK_World-Japan_TV.svg.png" tvg-id="NHKWorldJapan.jp" tvg-chno="22" group-title="News",NHK World Japan +https://ythls.armelin.one/channel/UCSPEjw8F2nQDtmUKPFNF7_A.m3u8 +#EXTINF:-1 tvg-name="DD India" tvg-logo="https://i.imgur.com/45uptR8.png" tvg-id="DDIndia.in" tvg-chno="23" group-title="News",DD India +https://ythls.armelin.one/channel/UCGDQNvybfDDeGTf4GtigXaw.m3u8 +#EXTINF:-1 tvg-name="WION" tvg-logo="https://i.imgur.com/Wc5Z3iS.png" tvg-id="WION.in" tvg-chno="24" group-title="News",WION +https://ythls.armelin.one/channel/UC_gUM8rL-Lrg6O3adPW9K1g.m3u8 +#EXTINF:-1 tvg-name="Taiwan+" tvg-logo="https://i.imgur.com/SfcZyqm.png" tvg-id="TaiwanPlusTV.tw" tvg-chno="25" group-title="News",Taiwan+ +https://ythls.armelin.one/channel/UC7c6rvyAZLpKGk8ttVnpnLA.m3u8 +#EXTINF:-1 tvg-name="Scripps News" tvg-logo="https://i.imgur.com/UfN6aAi.png" tvg-id="ScrippsNews.us" tvg-chno="28" group-title="News",Scripps News +https://content.uplynk.com/channel/4bb4901b934c4e029fd4c1abfc766c37.m3u8 +#EXTINF:-1 tvg-name="Global News" tvg-logo="https://i.imgur.com/xk1QOhW.png" tvg-id="GlobalNews.ca" tvg-chno="33" group-title="News",Global News +https://live.corusdigitaldev.com/groupd/live/49a91e7f-1023-430f-8d66-561055f3d0f7/live.isml/.m3u8 +#EXTINF:-1 tvg-name="Russia Today Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Russia-today-logo.svg/512px-Russia-today-logo.svg.png" tvg-id="RT.ru" tvg-chno="34" group-title="News",Russia Today Ⓖ +https://rt-glb.rttv.com/live/rtnews/playlist.m3u8 +#EXTINF:-1 tvg-name="CNN International" tvg-logo="https://raw.githubusercontent.com/tv-logo/tv-logos/main/countries/united-states/cnn-us.png" tvg-id="CNNInternationalEurope.us" tvg-chno="37" group-title="News",CNN International +https://turnerlive.warnermediacdn.com/hls/live/586495/cnngo/cnn_slate/VIDEO_0_3564000.m3u8 +#EXTINF:-1 tvg-name="GB News" tvg-logo="https://upload.wikimedia.org/wikipedia/en/thumb/3/35/GB_News_Logo.svg/512px-GB_News_Logo.svg.png" tvg-id="GBNews.uk" tvg-chno="38" group-title="News",GB News +https://ythls.armelin.one/channel/UC0vn8ISa4LKMunLbzaXLnOQ.m3u8 +#EXTINF:-1 tvg-name="SABC News" tvg-logo="https://i.imgur.com/H9q3Q9d.png" tvg-id="SABCNews.gh" tvg-chno="41" group-title="News",SABC News +https://sabconetanw.cdn.mangomolo.com/news/smil:news.stream.smil/chunklist_b250000_t64MjQwcA==.m3u8 +#EXTINF:-1 tvg-name="Bloomberg TV+" tvg-logo="https://i.imgur.com/xGlToly.png" tvg-id="BloombergTVPlus.us" tvg-chno="102" group-title="Business",Bloomberg TV+ +https://bloomberg.com/media-manifest/streams/phoenix-us.m3u8 +#EXTINF:-1 tvg-name="Bloomberg Television (US)" tvg-logo="https://i.imgur.com/OuogLHx.png" tvg-id="BloombergTV.us" tvg-chno="103" group-title="Business",Bloomberg Television (US) +https://bloomberg.com/media-manifest/streams/us.m3u8 +#EXTINF:-1 tvg-name="Bloomberg Television (Europe)" tvg-logo="https://i.imgur.com/OuogLHx.png" tvg-id="BloombergTVEurope.uk" tvg-chno="104" group-title="Business",Bloomberg Television (Europe) +https://bloomberg.com/media-manifest/streams/eu.m3u8 +#EXTINF:-1 tvg-name="Yahoo! Finance" tvg-logo="https://i.imgur.com/43oHsHL.png" tvg-id="YahooFinance.us" tvg-chno="105" group-title="Business",Yahoo! Finance +https://d1ewctnvcwvvvu.cloudfront.net/playlist.m3u8 +#EXTINF:-1 tvg-name="Ausbiz" tvg-logo="https://i.imgur.com/8vGGdB0.png" tvg-id="AusbizTV.au" tvg-chno="108" group-title="Business",Ausbiz +https://d9quh89lh7dtw.cloudfront.net/public-output/index.m3u8 +#EXTINF:-1 tvg-name="Moconomy" tvg-logo="https://i.imgur.com/GvqbLZB.png" tvg-id="MoconomyEconomyFinanceInfotainment.us" tvg-chno="109" group-title="Business",Moconomy +https://amogonetworx-moconomy-2-us.tcl.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="AccuWeather NOW" tvg-logo="https://i.imgur.com/M8wbVYK.png" tvg-id="AccuWeatherNOW.us" tvg-chno="201" group-title="Weather",AccuWeather NOW +https://cdn-ue1-prod.tsv2.amagi.tv/linear/amg00684-accuweather-accuweather-plex/playlist.m3u8 +#EXTINF:-1 tvg-name="WeatherNation" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/WeatherNation_Logo.svg/512px-WeatherNation_Logo.svg.png" tvg-id="WeatherNation.us" tvg-chno="204" group-title="Weather",WeatherNation +http://cfd-v4-service-channel-stitcher-use1-1.prd.pluto.tv/stitch/hls/channel/5d2cb7ac552e3773bc48982e/master.m3u8?appName=web&appVersion=unknown&clientTime=0&deviceDNT=0&deviceId=6c2d1028-30d3-11ef-9cf5-e9ddff8ff496&deviceMake=Chrome&deviceModel=web&deviceType=web&deviceVersion=unknown&includeExtendedEvents=false&serverSideAds=false&sid=dcca8395-396e-4be0-9049-564f29c5ac9b +#EXTINF:-1 tvg-name="Sky News Weather Ⓖ" tvg-logo="https://pbs.twimg.com/profile_images/1604994875459518464/lGt2wEqM_400x400.jpg" tvg-id="SkyNewsWeather.uk" tvg-chno="206" group-title="Weather",Sky News Weather Ⓖ +https://distro001-gb-hls1-prd.delivery.skycdp.com/easel_cdn/ngrp:weather_loop.stream_all/playlist.m3u8 +#EXTINF:-1 tvg-name="DW Español Ⓢ" tvg-logo="https://i.imgur.com/A1xzjOI.png" tvg-id="DWEspanol.de" tvg-chno="3" group-title="News (ES)",DW Español Ⓢ +https://dwamdstream104.akamaized.net/hls/live/2015530/dwstream104/stream04/streamPlaylist.m3u8 +#EXTINF:-1 tvg-name="RT Español Ⓖ" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Russia-today-logo.svg/512px-Russia-today-logo.svg.png" tvg-id="RTenEspanol.ru" tvg-chno="5" group-title="News (ES)",RT Español Ⓖ +https://rt-esp.rttv.com/dvr/rtesp/playlist.m3u8 +#EXTINF:-1 tvg-name="RTVE 24H" tvg-logo="https://i.imgur.com/WTDKOoM.png" tvg-id="rtve.es" tvg-chno="6" group-title="News (ES)",RTVE 24H +https://ztnr.rtve.es/ztnr/1694255.m3u8 +#EXTINF:-1 tvg-name="Sport2U" tvg-logo="https://i.imgur.com/WW0lNk1.png" tvg-chno="3" group-title="VOD Italy",Sport2U +https://stream9.xdevel.com/video0s976916-1685/stream/playlist_dvr.m3u8 +#EXTINF:-1 tvg-name="FailArmy" tvg-logo="https://i.imgur.com/WupT16d.jpg" tvg-chno="4110" group-title="VOD Italy",FailArmy +https://failarmy-international-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="The Pet Collective" tvg-logo="https://i.imgur.com/daTU44g.jpeg" tvg-chno="4116" group-title="VOD Italy",The Pet Collective +https://the-pet-collective-international-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="People Are Awesome" tvg-logo="https://i.imgur.com/xwz9zKk.jpeg" tvg-chno="4122" group-title="VOD Italy",People Are Awesome +https://jukin-peopleareawesome-2-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="Televisa Telenovelas" tvg-logo="https://i.imgur.com/GaJIRN3.jpg" tvg-chno="4169" group-title="VOD Italy",Televisa Telenovelas +https://televisa-televisa-1-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="SportOutdoor.tv" tvg-logo="https://i.imgur.com/fwOuEBl.png" tvg-chno="4513" group-title="VOD Italy",SportOutdoor.tv +https://gto2000-sportoutdoortv-1-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="CGtv" tvg-logo="https://i.imgur.com/6rsLtY7.png" tvg-chno="4972" group-title="VOD Italy",CGtv +https://cgentertainment-cgtv-1-it.samsung.wurl.tv/playlist.m3u8 +#EXTINF:-1 tvg-name="VH1+ Girl Power! – Pluto TV" tvg-logo="https://i.imgur.com/Z4t6fdU.png" group-title="VOD Italy",VH1+ Girl Power! – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/655208ff53fc9700084a834e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Car Chase – Pluto TV" tvg-logo="https://i.imgur.com/F1jXkhK.png" group-title="VOD Italy",Car Chase – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/65a939fad77d450008863835/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Monster Jam – Pluto TV" tvg-logo="https://i.imgur.com/jxGhINd.png" group-title="VOD Italy",Monster Jam – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/65bce7f1d77d450008b3a430/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="MacGyver – Pluto TV" tvg-logo="https://i.imgur.com/bz9IwWU.png" group-title="VOD Italy",MacGyver – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6245d4511358320007029cdf/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Settimo Cielo – Pluto TV" tvg-logo="https://i.imgur.com/TC5lo6r.png" group-title="VOD Italy",Settimo Cielo – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6245d3792792150007e20634/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="BBC Drama – Pluto TV" tvg-logo="https://i.imgur.com/CrAqXHJ.png" group-title="VOD Italy",BBC Drama – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e7fa5ab5062e0007dcf97d/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Le sorelle mcleod – Pluto TV" tvg-logo="https://i.imgur.com/P310ryf.png" group-title="VOD Italy",Le sorelle mcleod – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60a2837f8154ab0007c4dcdf/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Autostop per il cielo – Pluto TV" tvg-logo="https://i.imgur.com/rpC1qk7.png" group-title="VOD Italy",Autostop per il cielo – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/638f286445264d00084ec6dc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Renegade – Pluto TV" tvg-logo="https://i.imgur.com/Qsd9wuS.png" group-title="VOD Italy",Renegade – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/634926e4b51d2d00077819a2/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Serie – Pluto TV" tvg-logo="https://i.imgur.com/CHBhRZr.png" tvg-id="PlutoTVSerieItaly.it" group-title="VOD Italy",Serie – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60b9ff2722bfa400072676ef/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film – Pluto TV" tvg-logo="https://i.imgur.com/QeFc7F3.png" tvg-id="PlutoTVFilmItaly.it" group-title="VOD Italy",Film – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa17fb9f4490007e6419a/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Azione – Pluto TV" tvg-logo="https://i.imgur.com/ZmAuf3H.png" tvg-id="PlutoTVFilmAzioneItaly.it" group-title="VOD Italy",Film Azione – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa20a2e7f270007c4878d/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Thriller – Pluto TV" tvg-logo="https://i.imgur.com/hIWMGRW.png" tvg-id="PlutoTVFilmThrillerItaly.it" group-title="VOD Italy",Film Thriller – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa5e995132a00075f7005/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Horror – Pluto TV" tvg-logo="https://i.imgur.com/zhoHRSE.png" group-title="VOD Italy",Horror – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61c09e3ac210ed0007606620/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Western – Pluto TV" tvg-logo="https://i.imgur.com/6vmgQBl.png" group-title="VOD Italy",Western – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e7fb67478a5b0007e6c50c/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Classici – Pluto TV" tvg-logo="https://i.imgur.com/jNyZPLV.png" tvg-id="PlutoTVFilmClassiciItaly.it" group-title="VOD Italy",Film Classici – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa3c446d73500075f0e24/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Cinema Italiano – Pluto TV" tvg-logo="https://i.imgur.com/drDX5DC.png" tvg-id="PlutoTVCinemaItalianoItaly.it" group-title="VOD Italy",Cinema Italiano – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa7d8359b270007861489/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Totò – Pluto TV" tvg-logo="https://i.imgur.com/wLDteJZ.png" group-title="VOD Italy",Totò – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/65253f9881f942000833ccd5/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Romantici – Pluto TV" tvg-logo="https://i.imgur.com/KxPsLG3.png" tvg-id="PlutoTVFilmRomanticiItaly.it" group-title="VOD Italy",Film Romantici – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa4a4cc92820007b663af/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Christmas – Pluto TV" tvg-logo="https://i.imgur.com/tQ8lN30.png" group-title="VOD Italy",Christmas – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/612e05b885183d0007958101/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Drama – Pluto TV" tvg-logo="https://i.imgur.com/X8jimKP.png" tvg-id="PlutoTVFilmDramaItaly.it" group-title="VOD Italy",Film Drama – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa42b5c2b8f0007197529/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Film Commedia – Pluto TV" tvg-logo="https://i.imgur.com/cSKaVHW.png" tvg-id="PlutoTVFilmCommediaItaly.it" group-title="VOD Italy",Film Commedia – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa512d67fd900072323db/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="East is East – Pluto TV" tvg-logo="https://i.imgur.com/iPGv82f.png" group-title="VOD Italy",East is East – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/654c955683595c00081d9d87/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="The Asylum – Pluto TV" tvg-logo="https://i.imgur.com/S2jlaiJ.png" group-title="VOD Italy",The Asylum – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e8d5369e48940007fc1dc1/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Star Trek: The Original Series – Pluto TV" tvg-logo="https://i.imgur.com/VnUrFqB.png" group-title="VOD Italy",Star Trek: The Original Series – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6581a09edfed030008e12b39/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Doctor Who – Pluto TV" tvg-logo="https://i.imgur.com/oewPc3b.png" group-title="VOD Italy",Doctor Who – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e7f8db27ce19000732d1aa/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Sci-Fi – Pluto TV" tvg-logo="https://i.imgur.com/5B94N8g.png" tvg-id="PlutoTVSciFiItaly.it" group-title="VOD Italy",Sci-Fi – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61728bb9ee3773000840c1fa/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Mutant X – Pluto TV" tvg-logo="https://i.imgur.com/SptYKFI.png" group-title="VOD Italy",Mutant X – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60802c209a26320007c92ad5/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Andromeda – Pluto TV" tvg-logo="https://i.imgur.com/2HFhwX9.png" group-title="VOD Italy",Andromeda – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60802d37ee238e0007c94e64/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Sanctuary – Pluto TV" tvg-logo="https://i.imgur.com/iYyijeJ.png" group-title="VOD Italy",Sanctuary – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63eb57d6c111bc0008fe2658/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Forensic Files – Pluto TV" tvg-logo="https://i.imgur.com/hF5maH7.png" group-title="VOD Italy",Forensic Files – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/64ec5cf50f73a800081310a5/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Squadra Speciale Cobra 11 – Pluto TV" tvg-logo="https://i.imgur.com/HtDoAF2.png" group-title="VOD Italy",Squadra Speciale Cobra 11 – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/625e6cc905e09f00073addee/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Crime – Pluto TV" tvg-logo="https://i.imgur.com/WRMGIdb.png" tvg-id="PlutoTVCrimeItaly.it" group-title="VOD Italy",Crime – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa777b907770007e5d05d/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Mai dire sì – Pluto TV" tvg-logo="https://i.imgur.com/yIMyhSN.png" group-title="VOD Italy",Mai dire sì – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6349279ed5023700078f2bc2/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Consulenze illegali – Pluto TV" tvg-logo="https://i.imgur.com/AoOiYJX.png" group-title="VOD Italy",Consulenze illegali – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60b9dc99521a1400079bdfba/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Il banco dei pugni – Pluto TV" tvg-logo="https://i.imgur.com/2Lj1sdf.png" group-title="VOD Italy",Il banco dei pugni – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60e4507a06171800072339a3/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Affare Fatto – Pluto TV" tvg-logo="https://i.imgur.com/BuGkGsL.png" group-title="VOD Italy",Affare Fatto – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/64ec5c6644fe100009d114ae/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Catfish – Pluto TV" tvg-logo="https://i.imgur.com/KajHKoP.png" group-title="VOD Italy",Catfish – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6093f9ed2c75660007322bb7/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Il Testimone – Pluto TV" tvg-logo="https://i.imgur.com/9NXL9kY.png" group-title="VOD Italy",Il Testimone – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61fbd3f0733df400076c9a2d/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Case Pazzesche – Pluto TV" tvg-logo="https://i.imgur.com/v8IDWwX.png" group-title="VOD Italy",Case Pazzesche – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/626bb07a58b8dd0007e9f36e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Geordie Shore – Pluto TV" tvg-logo="https://i.imgur.com/y0fkHVi.png" group-title="VOD Italy",Geordie Shore – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/619263ee9541940007d20d60/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Ex on the beach – Pluto TV" tvg-logo="https://i.imgur.com/IONkdyk.png" group-title="VOD Italy",Ex on the beach – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60940ebad67fd900072382db/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="#Riccanza – Pluto TV" tvg-logo="https://i.imgur.com/oBpcWEm.png" group-title="VOD Italy",#Riccanza – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/626baa721c279b00072cbedd/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="The Hills – Pluto TV" tvg-logo="https://i.imgur.com/9SRojGx.png" group-title="VOD Italy",The Hills – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6466007181844c000967f80a/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Reality – Pluto TV" tvg-logo="https://i.imgur.com/tPEOeTk.png" tvg-id="PlutoTVRealityItaly.it" group-title="VOD Italy",Reality – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61925f874b1ec000075e700a/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Made – Pluto TV" tvg-logo="https://i.imgur.com/dguQsJ7.png" group-title="VOD Italy",Made – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6304ee20112ca70007d8accc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="16 anni e incinta – Pluto TV" tvg-logo="https://i.imgur.com/4BHrbP7.png" group-title="VOD Italy",16 anni e incinta – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60940a07d88ba90007b9cb71/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Teen Mom – Pluto TV" tvg-logo="https://i.imgur.com/LdiTgeW.png" group-title="VOD Italy",Teen Mom – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e7fc8c0d061100083946a9/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Jersey Shore – Pluto TV" tvg-logo="https://i.imgur.com/HIlmQEy.png" group-title="VOD Italy",Jersey Shore – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63eb5767da71180008ace8fc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Top Gear – Pluto TV" tvg-logo="https://i.imgur.com/ZZo7DXQ.png" group-title="VOD Italy",Top Gear – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/64c109a4798def0008a6e03e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Pimp My Ride – Pluto TV" tvg-logo="https://i.imgur.com/GC2wj2K.png" group-title="VOD Italy",Pimp My Ride – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/636a4eaf77279a0007f14861/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="World poker tour – Pluto TV" tvg-logo="https://i.imgur.com/AF1ON8u.png" group-title="VOD Italy",World poker tour – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608016e446d73500075ea7e0/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="PFL MMA – Pluto TV" tvg-logo="https://i.imgur.com/zScgLTv.png" group-title="VOD Italy",PFL MMA – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/654a299cab05240008a12639/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Robot Wars by MECH+ – Pluto TV" tvg-logo="https://i.imgur.com/vGqha3k.png" group-title="VOD Italy",Robot Wars by MECH+ – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/651581ba6a84140008593586/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Ultimate Classic Wrestling – Pluto TV" tvg-logo="https://i.imgur.com/QVN5qv3.png" group-title="VOD Italy",Ultimate Classic Wrestling – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/652516fb7971630008a58e74/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="The Boat Show – Pluto TV" tvg-logo="https://i.imgur.com/aDcwRvj.png" group-title="VOD Italy",The Boat Show – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63eb58aa60bc8f0008caa8f8/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Unbeaten Sports – Pluto TV" tvg-logo="https://i.imgur.com/UAiv612.png" group-title="VOD Italy",Unbeaten Sports – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/64c3b106dac71b00080a26d2/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Sport – Pluto TV" tvg-logo="https://i.imgur.com/o2psAYW.png" tvg-id="PlutoTVSportItaly.it" group-title="VOD Italy",Sport – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608030eff4b6f70007e1684c/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Ridiculousness – Pluto TV" tvg-logo="https://i.imgur.com/Yyj3Dm6.png" group-title="VOD Italy",Ridiculousness – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61fbd721e5b49e00079bfedc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Scherzi e risate – Pluto TV" tvg-logo="https://i.imgur.com/kPxGzPI.png" group-title="VOD Italy",Scherzi e risate – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/609404b0a8ec810007d8de9d/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Fail army – Pluto TV" tvg-logo="https://i.imgur.com/SqRDd2U.png" group-title="VOD Italy",Fail army – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608014d19a26320007c92ab6/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="People are awesome – Pluto TV" tvg-logo="https://i.imgur.com/jn0OeWv.png" group-title="VOD Italy",People are awesome – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608017cbe375e400070cc981/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="The pet collective – Pluto TV" tvg-logo="https://i.imgur.com/034PrSz.png" group-title="VOD Italy",The pet collective – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60801317a0ccef00072aaf75/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Just for laughs – Pluto TV" tvg-logo="https://i.imgur.com/VvEz0XX.png" group-title="VOD Italy",Just for laughs – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6093f48c95132a00075fd859/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="South Park – Pluto TV" tvg-logo="https://i.imgur.com/06wIdRV.png" group-title="VOD Italy",South Park – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62bc1f502b70e3000706298e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Spongebob – Pluto TV" tvg-logo="https://i.imgur.com/vFiwODM.png" group-title="VOD Italy",Super! Spongebob – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6093f9281db477000759fce0/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Due Fantagenitori – Pluto TV" tvg-logo="https://i.imgur.com/HOvLgGK.png" group-title="VOD Italy",Due Fantagenitori – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62b57a6752a0060008bc65cd/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Yu-Gi-Oh! – Pluto TV" tvg-logo="https://i.imgur.com/rmzSC1C.png" group-title="VOD Italy",Yu-Gi-Oh! – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63eb82e24e83e70008ab735f/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Anime – Pluto TV" tvg-logo="https://i.imgur.com/rhVF0eC.png" group-title="VOD Italy",Anime – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/65b90daed77d450008a43345/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Avatar – Pluto TV" tvg-logo="https://i.imgur.com/KDSStnJ.png" group-title="VOD Italy",Avatar – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/624da1cd2af90c0007c13205/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Inazuma Eleven – Pluto TV" tvg-logo="https://i.imgur.com/2u1ic6X.png" tvg-id="PlutoTVAnimeItaly.it" group-title="VOD Italy",Inazuma Eleven – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/612375086abc84000738fc03/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Rugrats – Pluto TV" tvg-logo="https://i.imgur.com/QqMQZte.png" group-title="VOD Italy",Super! Rugrats – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e8ddbbaed0390007b258a6/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Teenage Mutant Ninja Turtles – Pluto TV" tvg-logo="https://i.imgur.com/bAnyjH7.png" group-title="VOD Italy",Teenage Mutant Ninja Turtles – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62619405c733e8000732d1fe/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Icarly – Pluto TV" tvg-logo="https://i.imgur.com/Mhl252U.png" group-title="VOD Italy",Super! Icarly – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/609401db8cf51c00084b592e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Victorious – Pluto TV" tvg-logo="https://i.imgur.com/ArXLm5R.png" group-title="VOD Italy",Super! Victorious – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63c012504faf1c0007abfa93/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Zoey 101 – Pluto TV" tvg-logo="https://i.imgur.com/7FCQb1G.png" group-title="VOD Italy",Super! Zoey 101 – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6304ed62410a4c00083c0291/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! POP – Pluto TV" tvg-logo="https://i.imgur.com/jlz31HP.png" group-title="VOD Italy",Super! POP – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6093f7b5bb49b90007cecaad/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Gaming – Pluto TV" tvg-logo="https://i.imgur.com/UB7xHRQ.png" group-title="VOD Italy",Super! Gaming – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/626192c51c279b00072c4553/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Girl Power – Pluto TV" tvg-logo="https://i.imgur.com/lzgp9GM.png" group-title="VOD Italy",Super! Girl Power – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/636a4ce50728d200072eebe7/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Intervallo – Pluto TV" tvg-logo="https://i.imgur.com/jl7ZvE5.png" group-title="VOD Italy",Super! Intervallo – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62619544b1bf740007b4154b/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Spyders – Pluto TV" tvg-logo="https://i.imgur.com/n89yuZh.png" group-title="VOD Italy",Super! Spyders – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/655207f8b9c8a700082c7951/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Kids – Pluto TV" tvg-logo="https://i.imgur.com/ybd7A0r.png" group-title="VOD Italy",Kids – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62444e195d2ab7000861694b/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Kids Ukraine – Pluto TV" tvg-logo="https://i.imgur.com/MrIlypV.png" group-title="VOD Italy",Kids Ukraine – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/622a2d8da9d8210007d918c2/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Star – Pluto TV" tvg-logo="https://i.imgur.com/YLtqPkF.png" group-title="VOD Italy",Super! Star – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6093f6f8351eb0000754afb8/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Risate – Pluto TV" tvg-logo="https://i.imgur.com/ALCbqZt.png" group-title="VOD Italy",Super! Risate – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63eb573ba99571000897135b/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Super! Brothers and Sisters – Pluto TV" tvg-logo="https://i.imgur.com/KSQvVye.png" group-title="VOD Italy",Super! Brothers and Sisters – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61925ea79541940007d20881/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="VH1+ Music Legends – Pluto TV" tvg-logo="https://i.imgur.com/kJ1MDVi.png" group-title="VOD Italy",VH1+ Music Legends – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/62e8cc10ca869f00078efca8/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="VH1+ Back to 90's – Pluto TV" tvg-logo="https://i.imgur.com/vGgh3cs.png" group-title="VOD Italy",VH1+ Back to 90's – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6552085aab05240008b05f6c/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="VH1+ Rock! – Pluto TV" tvg-logo="https://i.imgur.com/P1OCuFl.png" group-title="VOD Italy",VH1+ Rock! – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/636a4173e34fd50007534542/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="VH1+ Canzoni Italiane – Pluto TV" tvg-logo="https://i.imgur.com/fpQBKP2.png" group-title="VOD Italy",VH1+ Canzoni Italiane – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63724270c7c2360007b170e8/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="VH1+ Hip Hop & Rap – Pluto TV" tvg-logo="https://i.imgur.com/k1zQpiO.png" group-title="VOD Italy",VH1+ Hip Hop & Rap – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6465fef47cb4b100086ee7bd/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Deluxe Lounge HD – Pluto TV" tvg-logo="https://i.imgur.com/mTXgaKl.png" group-title="VOD Italy",Deluxe Lounge HD – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6278ec6c33d85a00077ad814/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="K-Pop – Pluto TV" tvg-logo="https://i.imgur.com/9Vp4rx2.png" group-title="VOD Italy",K-Pop – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60c8b75fb0a68400074b86fc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Fireplace – Pluto TV" tvg-logo="https://i.imgur.com/csk1e76.png" group-title="VOD Italy",Fireplace – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/612ce23f51cce000078eeed5/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Euronews – Pluto TV" tvg-logo="https://i.imgur.com/Zpf2ykM.png" group-title="VOD Italy",Euronews – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/61b86ea479a4390007c6d5fc/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="CBS News – Pluto TV" tvg-logo="https://i.imgur.com/ApsK6Eq.png" group-title="VOD Italy",CBS News – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6231ec4b62cd1f0007093c7b/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Cucina – Pluto TV" tvg-logo="https://i.imgur.com/KsOhaOn.png" tvg-id="PlutoTVCucinaItaly.it" group-title="VOD Italy",Cucina – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa718a8ec810007d87fee/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Real life – Pluto TV" tvg-logo="https://i.imgur.com/FpwqJYw.png" tvg-id="PlutoTVRealLifeItaly.it" group-title="VOD Italy",Real life – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60801976f92a750007a0699c/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Naturescape – Pluto TV" tvg-logo="https://i.imgur.com/CGB6qiV.png" group-title="VOD Italy",Naturescape – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/610a9ebe8c2ac2000734776e/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Natura – Pluto TV" tvg-logo="https://i.imgur.com/Pc4gx12.png" tvg-id="PlutoTVNaturaItaly.it" group-title="VOD Italy",Natura – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/60802b37709d6b0007b0c549/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Storia – Pluto TV" tvg-logo="https://i.imgur.com/w5BYvsz.png" group-title="VOD Italy",Storia – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/65253fdf3fd33c00080214a3/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Documentari – Pluto TV" tvg-logo="https://i.imgur.com/0jYrODf.png" tvg-id="PlutoTVDocumentariItaly.it" group-title="VOD Italy",Documentari – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/608aa8a5709d6b0007b132fe/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Viaggi – Pluto TV" tvg-logo="https://i.imgur.com/3GJg0uy.png" group-title="VOD Italy",Viaggi – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/63c923944207be0007fd0887/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="House of Docs – Pluto TV" tvg-logo="https://i.imgur.com/pFLQ18m.png" group-title="VOD Italy",House of Docs – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/6536608b4f123d000876b78b/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel +#EXTINF:-1 tvg-name="Le vite degli altri – Pluto TV" tvg-logo="https://i.imgur.com/nngMFi5.png" group-title="VOD Italy",Le vite degli altri – Pluto TV +https://service-stitcher.clusters.pluto.tv/v1/stitch/embed/hls/channel/653660b4295b840008a70ba3/master.m3u8?deviceId=channel&deviceModel=web&deviceVersion=1.0&appVersion=1.0&deviceType=rokuChannel&deviceMake=rokuChannel&deviceDNT=1&advertisingId=channel&embedPartner=rokuChannel&appName=rokuchannel&is_lat=1&bmodel=bm1&content=channel&platform=web&tags=ROKU_CONTENT_TAGS&coppa=false&content_type=livefeed&rdid=channel&genre=ROKU_ADS_CONTENT_GENRE&content_rating=ROKU_ADS_CONTENT_RATING&studio_id=viacom&channel_id=channel diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..fd30e93 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build/ +/captures +.externalNativeBuild/ +.cxx/ +*.apk +*.aab +*.hprof +local.properties +/app/build/ +/release/ +*.keystore +*.jks +*.json +!app/google-services.json diff --git a/app/ARCHITECTURE.md b/app/ARCHITECTURE.md new file mode 100644 index 0000000..8676de9 --- /dev/null +++ b/app/ARCHITECTURE.md @@ -0,0 +1,136 @@ +# Architecture d'iMedia TV+ + +## Principes fondateurs + +- **Clean Architecture** simplifiée : UI → ViewModel → Repository → Data +- **Unidirectional Data Flow (UDF)** : les events montent, les states descendent +- **Single Activity** : une seule `MainActivity`, navigation par écrans Compose +- **Programmation réactive** : Kotlin Coroutines + Flow pour tout ce qui est async +- **Séparation claire** : chaque couche ignore l'existence des couches au-dessus d'elle + +## Flux de données complet + +```mermaid +sequenceDiagram + actor User as Utilisateur (Télécommande) + participant UI as Compose Screen + participant VM as ViewModel + participant Repo as Repository + participant API as Retrofit / Xtream + participant DB as Room (Cache) + participant Store as EncryptedSP + + User->>UI: Appuie sur OK (connexion) + UI->>VM: Event: Login(server, user, pass) + VM->>Repo: authRepository.login(server, user, pass) + Repo->>API: POST /player_api.php?username=... + API-->>Repo: Response { auth: "1", status: "Active" } + Repo->>Store: saveCredentials(encrypted) + Repo-->>VM: Result.Success(userInfo) + VM->>VM: _uiState = UiState.Success + VM-->>UI: StateFlow: HomeScreen data + UI-->>User: Affiche la liste des chaînes +``` + +## Diagramme de navigation des écrans + +```mermaid +stateDiagram-v2 + [*] --> SplashScreen + SplashScreen --> LoginScreen : Pas de session + SplashScreen --> HomeScreen : Session valide + + LoginScreen --> HomeScreen : Connexion OK + LoginScreen --> LoginScreen : Erreur → reste + + HomeScreen --> PlayerScreen : Sélection chaîne Live + HomeScreen --> ProgramGuideScreen : Appui Guide TV + HomeScreen --> ReplayGuideScreen : Appui Replay + HomeScreen --> VodScreen : Appui VOD + HomeScreen --> SeriesScreen : Appui Séries + + ProgramGuideScreen --> PlayerScreen : "Regarder" + ReplayGuideScreen --> PlayerScreen : "Revoir" + VodScreen --> PlayerScreen : Lecture film + SeriesScreen --> PlayerScreen : Lecture épisode + + PlayerScreen --> HomeScreen : Retour × 2 + PlayerScreen --> ProgramGuideScreen : Retour × 1 (si venu du guide) + PlayerScreen --> ReplayGuideScreen : Retour × 1 (si venu du replay) + + HomeScreen --> ConfirmQuit : Retour + ConfirmQuit --> [*] : Confirmer + ConfirmQuit --> HomeScreen : Annuler +``` + +## Organisation des ViewModels + +Chaque ViewModel expose : +- Un `StateFlow>` pour l'état de l'UI +- Des fonctions `onAction(X)` pour les intents utilisateur +- Une vue unique (`viewModelScope`) pour les coroutines + +```kotlin +class HomeViewModel( + private val liveRepo: LiveRepository, + private val epgRepo: EpgRepository, + private val favRepo: FavoritesRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + sealed interface Action { + data class SelectCategory(val id: Int) : Action + data class ToggleFavorite(val streamId: Int) : Action + data class PinChannel(val streamId: Int) : Action + object Refresh : Action + } + + fun onAction(action: Action) { + // ... + } +} +``` + +## Injection de dépendances + +Utilisation d'un **manual DI** simple via `AppContainer` (pas de framework lourd comme Hilt/Dagger pour un projet de cette taille). + +```kotlin +// AppContainer.kt +class AppContainer(context: Context) { + private val secureStorage = SecureStorage(context) + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(ErrorInterceptor()) + .addInterceptor(AuthInterceptor(secureStorage)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl("http://placeholder") // overridden per-call + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val xtreamApi = retrofit.create(XtreamApi::class.java) + + // Repositories + val authRepository = AuthRepository(xtreamApi, secureStorage) + val liveRepository = LiveRepository(xtreamApi) + val epgRepository = EpgRepository(xtreamApi, EpgDatabase.getInstance(context)) + // ... +} +``` + +## Décisions d'architecture + +| Décision | Alternative écartée | Raison | +|----------|-------------------|--------| +| Single Activity | Multi-Activity | Navigation + état simplifiés | +| Manual DI | Hilt / Koin | Pas de overhead pour projet solo | +| Room pour cache EPG | Simple JSON file | Requêtes typées, ACID, migrations | +| EncryptedSharedPreferences | DataStore | Chiffrement natif, API simple | +| ViewModel + StateFlow | MVVM + LiveData | Flow plus puissant, compose-ready | +| sealed interface pour actions | Callbacks | Testable, traçable, immutable | diff --git a/app/COMMANDES_BUILD_INSTALL_MIBOX.md b/app/COMMANDES_BUILD_INSTALL_MIBOX.md new file mode 100644 index 0000000..7a7d499 --- /dev/null +++ b/app/COMMANDES_BUILD_INSTALL_MIBOX.md @@ -0,0 +1,103 @@ +# Commandes PowerShell - build APK et installation Mi Box + +## 1. Ouvrir le dossier du projet + +```powershell +cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES" +``` + +Adapte le chemin si tu extrais le ZIP ailleurs. Oui, Windows aime transformer les chemins en mini-épreuves administratives. + +## 2. Préparer Java et ADB, si nécessaire + +```powershell +$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" +``` + +Vérification : + +```powershell +java -version +adb version +``` + +## 3. Créer l'APK installable debug + +```powershell +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK généré : + +```powershell +.\app\build\outputs\apk\debug\app-debug.apk +``` + +## 4. Connecter la Mi Box en ADB réseau + +Sur la Mi Box, active d'abord : + +- Options développeur +- Débogage USB / Débogage réseau +- Autoriser la connexion ADB quand la télévision affiche la demande + +Puis sur le PC : + +```powershell +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb devices +``` + +Si `adb devices` affiche `unauthorized`, regarde l'écran de la Mi Box et valide l'autorisation RSA. Cette étape existe pour rappeler que même les télévisions veulent signer des contrats. + +## 5. Installer l'APK sur la Mi Box + +```powershell +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +Si Android refuse à cause d'une version déjà installée : + +```powershell +adb install -r -d ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +Si le paquet est corrompu ou bloqué par une ancienne installation : + +```powershell +adb uninstall ma.tkgraph.xtreamtv +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +``` + +## 6. Lancer l'application après installation + +```powershell +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` + +Alternative : + +```powershell +adb shell monkey -p ma.tkgraph.xtreamtv 1 +``` + +## 7. Commande complète rapide + +```powershell +cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES" +$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` diff --git a/app/FOCUS_FIX_NOTES.md b/app/FOCUS_FIX_NOTES.md new file mode 100644 index 0000000..e781235 --- /dev/null +++ b/app/FOCUS_FIX_NOTES.md @@ -0,0 +1,28 @@ +# Correctifs focus Android TV - Hermes + +## Fichiers modifiés + +- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt` +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt` + +## Changements principaux + +1. La carte chaîne de l'écran Live est maintenant cliquable sur toute sa largeur. +2. Le `Text` du nom de chaîne n'est plus le seul élément cliquable. +3. Le focus est restauré sur la chaîne après retour depuis le player. +4. Le premier item de la liste reçoit le focus après chargement initial. +5. Le long appui OK ouvre la recherche uniquement sur l'écran Live, plus globalement dans `MainActivity`. +6. Les boutons Replay, Pin, Favori, Back, Guide et Replay ont un contour de focus plus visible. +7. Les champs Login ont une navigation DPAD haut/bas explicite pour éviter le curseur bloqué dans les zones de texte. +8. Dans le Player, OK affiche/masque l'overlay. + +## Note de compilation + +Le projet n'a pas été compilé dans cet environnement, car le wrapper Gradle doit télécharger `gradle-9.1.0-bin.zip` depuis `services.gradle.org`, et l'environnement d'analyse n'a pas d'accès réseau. Compile le projet sur ta machine Windows avec les commandes de `COMMANDES_BUILD_INSTALL_MIBOX.md`. diff --git a/app/FOCUS_FIX_V2_NOTES.md b/app/FOCUS_FIX_V2_NOTES.md new file mode 100644 index 0000000..40c2fdf --- /dev/null +++ b/app/FOCUS_FIX_V2_NOTES.md @@ -0,0 +1,28 @@ +# Correctif focus V2 - appui long OK + +## Changement appliqué + +Le déclenchement de la recherche par appui long sur OK a été supprimé dans `HomeScreen.kt`. + +Avant : +- Appui long OK, première séquence : ouverture de la recherche. +- Appui long OK ensuite : accès aux actions de la chaîne. + +Maintenant : +- La recherche reste accessible par le bouton `Rechercher…` dans le header avec les boutons directionnels. +- L'appui long OK n'est plus intercepté globalement par `HomeScreen` pour ouvrir la recherche. +- Le comportement d'accès aux boutons/actions de la chaîne n'est plus précédé par l'ouverture forcée de la recherche. + +## Fichier modifié + +- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt` + +## Détail technique + +Suppression du bloc : + +- `onPreviewKeyEvent` au niveau du `Box` principal. +- variable `okLongPressHandled`. +- import `android.view.KeyEvent as AndroidKeyEvent`. + +La recherche est maintenant volontairement limitée au champ/bouton de recherche visible dans le header. diff --git a/app/FOCUS_FIX_V3_NOTES.md b/app/FOCUS_FIX_V3_NOTES.md new file mode 100644 index 0000000..dbbf005 --- /dev/null +++ b/app/FOCUS_FIX_V3_NOTES.md @@ -0,0 +1,69 @@ +# Focus Fix V3 - Appui long OK sans déclenchement Replay + +## Problème corrigé + +Sur Android TV / Mi Box, l'appui long sur OK activait le mode actions, puis le relâchement du même OK pouvait être reçu par le premier bouton d'action, généralement `Replay`. + +Résultat : l'utilisateur voulait seulement accéder aux actions de la chaîne, mais l'application ouvrait Replay automatiquement. + +## Correction appliquée + +Fichier modifié : + +```text +app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt +``` + +Changements principaux : + +1. La carte chaîne intercepte elle-même OK : + - OK court = lire la chaîne. + - OK long = ouvrir le mode actions. + - Le relâchement OK après un appui long est consommé, donc il ne déclenche plus `Replay`. + +2. Le mode actions n'envoie plus automatiquement le focus sur `Replay`. + - Après appui long OK, la ligne passe en mode actions. + - L'utilisateur appuie ensuite sur droite pour aller vers `Replay`, `Pin`, ou `Favori`. + - Cela évite les faux clics liés à un appui long difficile à doser. + +3. Les actions `Replay`, `Pin`, `Favori` ne sont focusables/clickables que lorsque le mode actions est actif. + +4. BACK ferme le mode actions et remet le focus sur la carte chaîne. + +## Nouvelle logique d'utilisation + +- OK court sur une chaîne : lancer la chaîne. +- OK long sur une chaîne : ouvrir les actions. +- Droite : entrer dans les boutons de la chaîne. +- Gauche depuis les boutons : revenir à la carte chaîne. +- BACK : fermer le mode actions. + +## Compilation + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" + +$env:JAVA_HOME="D:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk" +$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path" + +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK : + +```powershell +.\app\build\outputs\apk\debug\app-debug.apk +``` + +## Installation Mi Box + +```powershell +adb kill-server +adb start-server +adb connect 192.168.1.156:5555 +adb devices +adb install -r ".\app\build\outputs\apk\debug\app-debug.apk" +adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity +``` diff --git a/app/SPECIFICATIONS.md b/app/SPECIFICATIONS.md new file mode 100644 index 0000000..71f0c4d --- /dev/null +++ b/app/SPECIFICATIONS.md @@ -0,0 +1,486 @@ +# iMedia TV+ / Xtream Google TV Player (Hermes Edition) +## Spécifications, architecture et guide du projet + +**Projet :** Lecteur IPTV Xtream pour Google TV / Android TV +**Package Android :** `ma.tkgraph.xtreamtv` +**Plateforme cible :** Google TV / Android TV +**Langage :** Kotlin +**Interface :** Jetpack Compose / Compose TV +**Lecteur vidéo :** AndroidX Media3 ExoPlayer +**API IPTV :** Xtream Codes compatible +**Date du document :** 11/06/2026 +**Version :** 2.0 (Hermes) + +--- + +## 1. Objectif du projet + +Développer une application Google TV complète pour lire des contenus IPTV depuis un serveur Xtream, en mettant l'accent sur : + +- **Qualité logicielle** : tests, architecture propre, sécurité +- **Expérience utilisateur** : navigation fluide, télécommande, réactivité +- **Résilience** : gestion d'erreur, cache, fallbacks gracieux +- **Maintenabilité** : documentation, historique, conventions + +Fonctionnalités couvertes : +- Connexion à un serveur Xtream + mémorisation sécurisée des identifiants +- Lecture Live TV avec EPG court +- Guide TV global + Replay / Catch-up TV +- VOD et Séries avec catalogues complets +- Favoris persistants (Live, VOD, Séries) +- Chaîne épinglée au démarrage +- Navigation complète à la télécommande Google TV +- Support multilingue (français, anglais, arabe) +- Cache local EPG pour performances + +--- + +## 2. Architecture + +### 2.1. Diagramme de flux + +```mermaid +flowchart TB + subgraph UI ["Couche UI (Compose TV)"] + Login[LoginScreen] + Home[HomeScreen] + Player[PlayerScreen] + Guide[ProgramGuideScreen] + Replay[ReplayGuideScreen] + Vod[VodScreen] + Series[SeriesScreen] + end + + subgraph ViewModel ["Couche ViewModel"] + VM_Home[HomeViewModel] + VM_Player[PlayerViewModel] + VM_Vod[VodViewModel] + VM_Series[SeriesViewModel] + end + + subgraph Repository ["Couche Repository"] + Repo_Auth[AuthRepository] + Repo_Live[LiveRepository] + Repo_Vod[VodRepository] + Repo_Series[SeriesRepository] + Repo_Epg[EpgRepository] + end + + subgraph Data ["Couche Data"] + API[XtreamApi
Retrofit] + Cache[EpgCache
Room / SQLite] + Storage[ConfigStorage
EncryptedSharedPreferences] + end + + subgraph External ["Externe"] + Xtream[Xtream Server] + end + + Login --> VM_Home + Home --> VM_Home + Player --> VM_Player + Guide --> VM_Player + Replay --> VM_Player + Vod --> VM_Vod + Series --> VM_Series + + VM_Home --> Repo_Auth + VM_Home --> Repo_Live + VM_Home --> Repo_Epg + VM_Player --> Repo_Live + VM_Player --> Repo_Epg + VM_Vod --> Repo_Vod + VM_Series --> Repo_Series + + Repo_Auth --> Storage + Repo_Live --> API + Repo_Vod --> API + Repo_Series --> API + Repo_Epg --> API + Repo_Epg --> Cache + + API --> Xtream + Storage -.->|Fallback| API +``` + +### 2.2. Flux de navigation + +```mermaid +flowchart LR + Splash[Splash
iMedia TV+] --> Login[LoginScreen] + Login --> Home[HomeScreen] + + Home -->|Live TV| LiveList[Liste Chaînes] + Home -->|Guide TV| GuideScreen[Guide TV Global] + Home -->|VOD| VodScreen[Catalogue VOD] + Home -->|Séries| SeriesScreen[Catalogue Séries] + + LiveList --> Player[PlayerScreen] + GuideScreen --> Player + ReplayGuide --> Player + VodScreen --> Player + SeriesScreen --> Player + + Player -->|Retour 1| Overlay + Player -->|Retour 2| PreviousScreen + + Home -->|Retour| ConfirmQuit{Confirmer?} +``` + +### 2.3. Structure des dossiers + +``` +app/ + └── src/ + ├── main/ + │ ├── AndroidManifest.xml + │ ├── java/ma/tkgraph/xtreamtv/ + │ │ ├── MainActivity.kt + │ │ ├── data/ + │ │ │ ├── model/ ← DTOs Xtream + │ │ │ ├── network/ ← Retrofit API + interceptors + │ │ │ ├── repository/ ← Live, VOD, Series, EPG, Auth + │ │ │ └── storage/ ← EncryptedSharedPreferences + │ │ ├── ui/ + │ │ │ ├── components/ ← ChannelLogo, CategoryRow, etc. + │ │ │ ├── screens/ ← Login, Home, Player, Guide, etc. + │ │ │ └── theme/ ← Thème Material 3 + │ │ └── util/ ← UrlBuilder, CategorySort, Extensions + │ └── res/ + │ ├── drawable/ ← launch_background.xml + │ ├── drawable-nodpi/ ← iMedia TV+ splash PNG + │ ├── mipmap-*/ ← icône app + │ ├── values/ ← strings.xml (default = EN) + │ ├── values-fr/ ← strings.xml (français) + │ └── values-ar/ ← strings.xml (العربية) + ├── test/ ← Tests unitaires (JUnit + MockK) + └── androidTest/ ← Tests UI (Compose Test) +``` + +--- + +## 3. Stack technique détaillée + +| Couche | Technologie | Justification | +|--------|-------------|---------------| +| Langage | Kotlin 2.x | Moderne, sûr, natif Android | +| UI | Jetpack Compose + Compose TV (1.7+) | Déclaratif, natif TV | +| Lecteur | AndroidX Media3 ExoPlayer 1.5+ | Standard Google, HLS/DASH/MP4 | +| Réseau | Retrofit 2.11+ / OkHttp 4.12+ | Robuste, interceptable | +| Images | Coil 3.x | Asynchrone, cache, fallback | +| Cache EPG | Room 2.7+ (SQLite) | Performant, requêtes typées | +| Stockage | EncryptedSharedPreferences + AES-256 | Sécurité des credentials | +| Tests unitaires | JUnit 5 / MockK / Turbine | Kotlin-friendly, Flow test | +| Tests UI | Compose UI Test + Espresso | Automatisation UI TV | +| Logging | Timber | Structuré, tags, crash | +| Crash reporting | Firebase Crashlytics | Monitoring production | + +--- + +## 4. Environnement de développement + +| Composant | Valeur | +|-----------|--------| +| OS de développement | Windows 11 | +| IDE | Android Studio (Ladybug+) | +| Terminal | PowerShell | +| Appareil cible | Google TV / Android TV 12+ (API 31+) | +| Connexion | ADB Wi-Fi ou USB | +| JDK | OpenJDK 21 (via Android Studio) | +| SDK Android | API 36, Build-Tools 36.x | +| Gradle | 9.1+ (wrapper) | + +### 4.1. Variables d'environnement + +```powershell +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:Path = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;D:\Tools\Gradle\gradle-9.1.0\bin;$env:Path" +``` + +### 4.2. Compilation + +```powershell +cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES" +.\gradlew.bat clean --no-daemon +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +APK générée : `app\build\outputs\apk\debug\app-debug.apk` + +--- + +## 5. Sécurité + +### 5.1. Stockage des identifiants + +**Problème de la v1 :** identifiants stockés en clair dans `SharedPreferences`. + +**Solution Hermes :** `EncryptedSharedPreferences` avec AES-256 GCM. + +```kotlin +// data/storage/SecureStorage.kt +val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + +val prefs = EncryptedSharedPreferences.create( + context, + "imedia_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM +) +``` + +### 5.2. Règles de sécurité + +- Aucun log contenant `username`, `password` ou `server_url` +- Timeout réseau configuré (15s connect, 30s read) +- Validation côté client avant envoi : URL bien formée, champs non vides +- Pas de stockage en cache HTTP des credentials +- OkHttp interceptor pour masquer les auth dans les logs debug +- Préparation au HTTPS (certificats, SSR) + +--- + +## 6. Gestion d'erreurs + +### 6.1. Stratégie générale + +| Scénario | Comportement | Message utilisateur | +|----------|-------------|---------------------| +| Réseau indisponible | Toast + fallback cache EPG | "Connexion impossible. Vérifiez votre réseau." | +| Timeout API (15s) | Retry 1x, puis message | "Le serveur ne répond pas. Réessayez." | +| Auth échouée | Effacer champs, focus sur login | "Identifiants incorrects ou serveur invalide." | +| Flux vidéo mort | 503 overlay, retour menu | "Flux indisponible pour cette chaîne." | +| EPG vide | Afficher "Aucun programme" | "Aucun programme trouvé pour cette chaîne." | +| Données corrompues | Fallback valeurs par défaut | "Des données sont manquantes — certains affichages peuvent être incomplets." | + +### 6.2. État UI avec sealed class + +```kotlin +sealed class UiState { + object Idle : UiState() + object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String, val retry: Boolean = true) : UiState() +} +``` + +### 6.3. Intercepteur réseau + +```kotlin +class ErrorInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return try { + val response = chain.proceed(request) + if (!response.isSuccessful) { + throw XtreamHttpException(response.code, response.message) + } + response + } catch (e: SocketTimeoutException) { + throw XtreamTimeoutException("Le serveur Xtream est injoignable") + } catch (e: UnknownHostException) { + throw XtreamNetworkException("Aucune connexion réseau") + } + } +} +``` + +--- + +## 7. Cache EPG + +### 7.1. Problème + +Le guide TV global peut nécessiter des centaines d'appels API (1 par chaîne). Sans cache, l'écran est inutilisable. + +### 7.2. Solution : Room (SQLite) + +```kotlin +@Entity(tableName = "epg_cache") +data class EpgCacheEntry( + @PrimaryKey val id: Long, + val streamId: Int, + val channelName: String, + val title: String, + val description: String?, + val startTimestamp: Long, + val endTimestamp: Long, + val cachedAt: Long = System.currentTimeMillis() +) +``` + +### 7.3. Politique de cache + +| Type | Durée | Invalidation | +|------|-------|-------------| +| EPG court (live) | 2 minutes | Rafraîchi au scroll | +| EPG complet (guide) | 15 minutes | Pull-to-refresh manuel | +| Catégories | 1 heure | Au lancement | +| Listes chaînes | 5 minutes | Changement de catégorie | + +### 7.4. Flux EPG + +```mermaid +flowchart TD + A[Ouvre Guide TV] --> B{Cache valide?} + B -->|Oui < 15min| C[Afficher depuis Room] + B -->|Non| D[Requête API par chaîne] + D --> E[Stocker dans Room] + E --> C +``` + +--- + +## 8. Tests + +### 8.1. Tests unitaires (JUnit 5 + MockK) + +| Fichier | Test | Ce qu'on vérifie | +|---------|------|------------------| +| `AuthRepositoryTest.kt` | Connexion valide/invalide | Repository retourne `Success` ou `Error` | +| `AuthRepositoryTest.kt` | Timeout réseau | Erreur lisible, pas de crash | +| `EpgRepositoryTest.kt` | Cache hit/miss | Appel API si cache expiré, sinon données locales | +| `CategorySortUtilsTest.kt` | Tri FR/AR/autres | Ordre exact des catégories | +| `XtreamUrlBuilderTest.kt` | URLs Live/VOD/Series | Format correct de chaque URL | +| `SecureStorageTest.kt` | Lecture/écriture chiffrée | Données diffférentes en clair vs stocké | +| `PlayerViewModelTest.kt` | Zapping gauche/droite | Chaîne suivante/précédente correcte | +| `FavoritesRepositoryTest.kt` | Ajout/suppression favoris | Persistance après redémarrage | + +### 8.2. Tests UI (Compose UI Test) + +| Fichier | Test | +|---------|------| +| `LoginScreenTest.kt` | Remplissage formulaire → bouton actif | +| `LoginScreenTest.kt` | Erreur → message d'erreur affiché | +| `HomeScreenTest.kt` | Navigation entre catégories OK | +| `HomeScreenTest.kt` | Favori accessible via télécommande | +| `PlayerScreenTest.kt` | Overlay disparaît après 3s | + +### 8.3. Lancer les tests + +```powershell +# Tests unitaires uniquement +.\gradlew.bat test --no-daemon + +# Tests UI (appareil/émulateur connecté) +.\gradlew.bat connectedAndroidTest --no-daemon + +# Les deux +.\gradlew.bat test connectedAndroidTest --no-daemon +``` + +--- + +## 9. Internationalisation (i18n) + +### 9.1. Langues supportées + +| Langue | Dossier | Statut | +|--------|---------|--------| +| Anglais (défaut) | `values/` | ✅ Prévu | +| Français | `values-fr/` | ✅ Prévu | +| Arabe | `values-ar/` | ✅ Prévu (RTL) | + +### 9.2. Exemple + +```xml + +iMedia TV+ +Live TV +Connect +Connection lost. Check your network. + + +iMedia TV+ +Direct TV +Connexion +Connexion perdue. Vérifiez votre réseau. + + +iMedia TV+ +البث المباشر +اتصال +انقطع الاتصال. تحقق من شبكتك. +``` + +--- + +## 10. Logging et Monitoring + +### 10.1. Timber + +```kotlin +// Application.kt +class XtreamApplication : Application() { + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(CrashReportingTree()) + } + } +} +``` + +Règles : +- `Timber.d` / `Timber.i` pour debug et infos +- `Timber.w` pour anomalies non bloquantes +- `Timber.e` pour erreurs utilisateur +- **Jamais** de log de credentials, tokens, ou données sensibles + +### 10.2. Crash reporting + +Firebase Crashlytics est prévu pour la version release : +- Rapports de crash automatiques +- Logs non-sensibles attachés aux rapports +- Clés custom : écran actif, dernier état réseau + +--- + +## 11. Roadmap + +### Phase 1 — Fondations (immédiat) +- [x] Structure du projet +- [x] Stack technique définie +- [ ] Connexion Xtream + SecureStorage +- [ ] Live TV avec Media3 + +### Phase 2 — Fonctionnalités cœur +- [ ] Catégories + Favoris Live +- [ ] EPG court + Replay +- [ ] Guide TV global +- [ ] VOD + Séries + +### Phase 3 — Qualité +- [ ] Tests unitaires (couverture > 70%) +- [ ] Cache EPG (Room) +- [ ] Gestion d'erreur complète +- [ ] i18n (FR, EN, AR) + +### Phase 4 — Production +- [ ] Signing + ProGuard +- [ ] Firebase Crashlytics +- [ ] Bannière Android TV + icônes finales +- [ ] Politique de confidentialité +- [ ] Publication Play Store + +--- + +## 12. Conclusion + +Cette version Hermes reprend l'excellente base de la v1 et y ajoute : + +1. **🔐 Sécurité** : chiffrement AES-256 des credentials +2. **📦 Cache EPG** : Room SQLite pour des performances réelles +3. **🧪 Tests** : stratégie complète (unitaire + UI) +4. **🌍 i18n** : support FR/EN/AR +5. **📋 Gestion d'erreur** : sealed class + intercepteurs + messages lisibles +6. **📊 Monitoring** : Timber + Crashlytics +7. **📐 Architecture** : flux documentés en Mermaid + +Le logiciel fonctionnait déjà en v1. Cette version le rend fiable, sécurisé et prêt pour la production. diff --git a/app/TESTING.md b/app/TESTING.md new file mode 100644 index 0000000..e29a79f --- /dev/null +++ b/app/TESTING.md @@ -0,0 +1,130 @@ +# Stratégie de test — iMedia TV+ + +## Principes + +- **TDD partout où c'est possible** : écrire le test avant l'implémentation +- **Couverture cible** : > 70% sur les couches `data/` et `util/` +- **Tests UI** : parcours critiques uniquement (login, lecture, favoris) +- **Exécution locale** : tous les tests doivent passer avant chaque commit + +## Stack de test + +| Technologie | Usage | +|-------------|-------| +| JUnit 5 | Runner principal | +| MockK | Mocking Kotlin (classes, objects, extensions) | +| Turbine | Test des Flow (StateFlow, SharedFlow) | +| Kotlinx Coroutines Test | `runTest`, `TestDispatcher` | +| Compose UI Test | Test des écrans Compose TV | +| Espresso | Complément pour interactions système | + +## Tests unitaires + +### data/repository/ + +| Fichier | Scénarios | +|---------|-----------| +| `AuthRepositoryTest.kt` | ✅ Réponse valide → `auth == "1"` → `Success` | +| | ✅ Réponse valide → `auth == "0"` → `Error("Identifiants incorrects")` | +| | ✅ Timeout → `Error("Le serveur ne répond pas")` | +| | ✅ Socket error → `Error("Aucune connexion réseau")` | +| `LiveRepositoryTest.kt` | ✅ Catégories chargées → liste triée FR/AR/autres | +| | ✅ Chaînes filtrées par catégorie | +| | ✅ Flux vide → liste vide (pas de crash) | +| `EpgRepositoryTest.kt` | ✅ Cache valide (< 2min) → pas d'appel API | +| | ✅ Cache expiré → appel API + mise à jour cache | +| | ✅ Pas de cache → appel API + insertion | +| | ✅ EPG vide → `Error("Aucun programme")` | +| `FavoritesRepositoryTest.kt` | ✅ Ajout favori → liste contient l'ID | +| | ✅ Suppression → liste ne contient plus l'ID | +| | ✅ Persistance → données conservées après recreation du repo | +| `SeriesRepositoryTest.kt` | ✅ Détail série retourne saisons + épisodes | +| | ✅ Épisode sans titre → fallback "Épisode N" | + +### data/storage/ + +| Fichier | Scénarios | +|---------|-----------| +| `SecureStorageTest.kt` | ✅ Valeur écrite = valeur lue | +| | ✅ Données différentes après clair vs chiffré | +| | ✅ Clear supprime toutes les données | +| | ✅ Valeur inexistante → null safe | + +### util/ + +| Fichier | Scénarios | +|---------|-----------| +| `CategorySortUtilsTest.kt` | ✅ Catégories FR en premier | +| | ✅ Catégories AR ensuite | +| | ✅ Catégories "Toutes" et "Favoris" toujours en haut | +| | ✅ Autres en dernier | +| | ✅ Ordre alphabétique à l'intérieur des groupes | +| `XtreamUrlBuilderTest.kt` | ✅ URL Live correcte avec m3u8 | +| | ✅ URL VOD avec extension dynamique | +| | ✅ URL série avec épisode_id | +| | ✅ URL Replay avec timestamp formaté | + +## Tests d'intégration / UI + +| Fichier | Parcours | +|---------|----------| +| `LoginScreenTest.kt` | ✅ Remplit les champs → bouton Connexion activé | +| | ✅ Champs vides → bouton désactivé | +| | ✅ Erreur réseau → message d'erreur visible | +| | ✅ Connexion OK → navigation vers HomeScreen | +| `HomeScreenTest.kt` | ✅ Catégories affichées au chargement | +| | ✅ Changement de catégorie → nouvelles chaînes | +| | ✅ Favori accessible avec télécommande (DPAD) | +| | ✅ Recherche par nom fonctionne | +| `PlayerScreenTest.kt` | ✅ Overlay visible → disparaît après 3s | +| | ✅ Zapping gauche → chaîne précédente | +| | ✅ Premier retour → overlay réapparaît | +| | ✅ Deuxième retour → retour à HomeScreen | + +## Exécution + +```powershell +# Tout +.\gradlew.bat test connectedAndroidTest --no-daemon + +# Tests unitaires uniquement +.\gradlew.bat test --no-daemon + +# Tests UI +.\gradlew.bat connectedAndroidTest --no-daemon + +# Filtre par classe +.\gradlew.bat test --no-daemon --tests *AuthRepositoryTest* +.\gradlew.bat connectedAndroidTest --no-daemon --tests *LoginScreenTest* + +# Rapport de couverture +.\gradlew.bat :app:createDebugCoverageReport --no-daemon +# HTML → app/build/reports/coverage/debug/index.html +``` + +## Convention d'écriture + +```kotlin +class AuthRepositoryTest { + private val api = mockk() + private val storage = mockk() + private val repo = AuthRepository(api, storage) + + @Test + fun `login with valid credentials returns Success`() = runTest { + // Given + coEvery { api.authenticate(any(), any(), any()) } returns AuthResponse( + userInfo = UserInfo(username = "test"), + auth = "1", + status = "Active" + ) + + // When + val result = repo.login("http://server", "user", "pass") + + // Then + assertTrue(result is UiState.Success) + assertEquals("test", (result as UiState.Success).data.username) + } +} +``` diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts new file mode 100644 index 0000000..7574e3c --- /dev/null +++ b/app/app/build.gradle.kts @@ -0,0 +1,123 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") version "2.1.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" +} + +android { + namespace = "ma.tkgraph.xtreamtv" + compileSdk = 36 + + defaultConfig { + applicationId = "ma.tkgraph.xtreamtv" + minSdk = 30 + targetSdk = 36 + versionCode = 2 + versionName = "2.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + isMinifyEnabled = false + buildConfigField("boolean", "ENABLE_LOGGING", "true") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("boolean", "ENABLE_LOGGING", "false") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // --- Compose / TV --- + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.tv:tv-material:1.0.0") + implementation("androidx.tv:tv-foundation:1.0.0") + + // --- Activity / Lifecycle --- + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + + // --- Media3 ExoPlayer --- + implementation("androidx.media3:media3-exoplayer:1.5.1") + implementation("androidx.media3:media3-exoplayer-hls:1.5.1") + implementation("androidx.media3:media3-exoplayer-dash:1.5.1") + implementation("androidx.media3:media3-ui:1.5.1") + implementation("androidx.media3:media3-session:1.5.1") + + // --- Network --- + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // --- Images --- + implementation("io.coil-kt.coil3:coil-compose:3.0.4") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") + + // --- Security (EncryptedSharedPreferences) --- + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // --- EPG Cache (Room) --- + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + annotationProcessor("androidx.room:room-compiler:2.6.1") + + // --- Logging --- + implementation("com.jakewharton.timber:timber:5.0.1") + + // --- Core --- + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.leanback:leanback:1.2.0-alpha04") + + // ========== TESTS ========== + // Unit tests + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("io.mockk:mockk:1.13.14") + testImplementation("app.cash.turbine:turbine:1.2.0") + + // Android / UI tests + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.test:runner:1.6.2") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro new file mode 100644 index 0000000..135b54a --- /dev/null +++ b/app/app/proguard-rules.pro @@ -0,0 +1,37 @@ +# --- iMedia TV+ ProGuard Rules --- + +# Timber — supprimer les logs debug en release +-assumenosideeffects class timber.log.Timber { + public static *** d(...); + public static *** v(...); + public static *** w(...); + public static *** i(...); +} + +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# Gson +-keepattributes Signature +-keep class com.google.gson.** { *; } +-keep class ma.tkgraph.xtreamtv.data.model.** { *; } + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + +# Coil +-keep class coil.** { *; } + +# Security Crypto +-keep class androidx.security.crypto.** { *; } diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..89dab28 --- /dev/null +++ b/app/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt new file mode 100644 index 0000000..650c10e --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt @@ -0,0 +1,53 @@ +package ma.tkgraph.xtreamtv + +import android.content.Context +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.repository.AuthRepository +import ma.tkgraph.xtreamtv.data.repository.EpgRepository +import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository +import ma.tkgraph.xtreamtv.data.repository.LiveRepository +import ma.tkgraph.xtreamtv.data.repository.M3uRepository +import ma.tkgraph.xtreamtv.data.repository.SeriesRepository +import ma.tkgraph.xtreamtv.data.repository.VodRepository +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor +import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * Container d'injection de dépendances manuel. + * Pas de Hilt/Dagger pour garder le projet léger. + */ +class AppContainer(context: Context) { + + val secureStorage = SecureStorage(context) + + val m3uRepository = M3uRepository(secureStorage) + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(XtreamBaseUrlInterceptor()) + .addInterceptor(XtreamErrorInterceptor()) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .writeTimeout(8, TimeUnit.SECONDS) + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl("http://localhost") // Sera remplacé dynamiquement + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java) + + // ─── Repositories ─── + val authRepository = AuthRepository(xtreamApi, secureStorage) + val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository) + val epgRepository = EpgRepository(xtreamApi, secureStorage) + val vodRepository = VodRepository(xtreamApi, secureStorage) + val seriesRepository = SeriesRepository(xtreamApi, secureStorage) + val favoritesRepository = FavoritesRepository(secureStorage) +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt new file mode 100644 index 0000000..a16a6fa --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt @@ -0,0 +1,112 @@ +package ma.tkgraph.xtreamtv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import ma.tkgraph.xtreamtv.ui.navigation.NavState +import ma.tkgraph.xtreamtv.ui.screens.HomeScreen +import ma.tkgraph.xtreamtv.ui.screens.LoginScreen +import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen +import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen +import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen +import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen +import ma.tkgraph.xtreamtv.ui.screens.VodScreen +import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme +import ma.tkgraph.xtreamtv.util.SessionManager + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntentExtras() + setContent { + XtreamTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AppNavigation() + } + } + } + } + + private fun handleIntentExtras() { + val serverUrl = intent.getStringExtra("server_url") + val username = intent.getStringExtra("xtream_user") + val password = intent.getStringExtra("xtream_pass") + if (serverUrl != null && username != null && password != null) { + XtreamApplication.instance.appContainer.secureStorage + .saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig( + serverUrl = serverUrl, + username = username, + password = password + )) + } + } +} + +/** + * Navigation principale. + * L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen + * qui vérifie le pin au chargement des streams. + */ +@Composable +fun AppNavigation() { + val sessionManager = SessionManager(XtreamApplication.instance.appContainer.secureStorage) + val hasSession = sessionManager.hasValidSession() + var navState by remember { mutableStateOf(NavState.Home()) } + var hasAutoPlayedPin by remember { mutableStateOf(false) } + + if (!hasSession) { + LoginScreen() + return + } + + when (val state = navState) { + is NavState.Home -> HomeScreen( + focusStreamId = state.focusStreamId, + onNavigate = { navState = it }, + hasAutoPlayedPin = hasAutoPlayedPin, + onAutoPlayPin = { hasAutoPlayedPin = true } + ) + + is NavState.Player -> PlayerScreen( + initialStream = state.stream, + allStreams = state.allStreams, + initialIndex = state.currentIndex, + replayUrl = state.replayUrl, + onBack = { + navState = state.returnState ?: NavState.Home() + } + ) + is NavState.ProgramGuide -> ProgramGuideScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.Home() } + ) + + is NavState.ReplayGuide -> ReplayGuideScreen( + stream = state.stream, + onBack = { navState = NavState.Home() }, + onPlayReplay = { url -> + navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url) + } + ) + + is NavState.Vod -> VodScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.Home() } + ) + + is NavState.Series -> SeriesScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.Home() } + ) + + is NavState.SeriesDetail -> SeriesScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.Home() } + ) + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt new file mode 100644 index 0000000..cf5096a --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt @@ -0,0 +1,36 @@ +package ma.tkgraph.xtreamtv + +import android.app.Application +import timber.log.Timber + +/** + * Point d'entrée de l'application iMedia TV+. + * Configure le logging et l'injection de dépendances. + */ +class XtreamApplication : Application() { + + /** Container d'injection de dépendances (manual DI). */ + lateinit var appContainer: AppContainer + private set + + override fun onCreate() { + super.onCreate() + instance = this + appContainer = AppContainer(this) + initLogging() + } + + private fun initLogging() { + if (BuildConfig.ENABLE_LOGGING) { + Timber.plant(Timber.DebugTree()) + } else { + // En production : Crashlytics tree (à configurer) + // Timber.plant(CrashReportingTree()) + } + } + + companion object { + lateinit var instance: XtreamApplication + private set + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt new file mode 100644 index 0000000..4522838 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt @@ -0,0 +1,23 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Réponse de l'API /player_api.php?username=...&password=... + */ +data class AuthResponse( + @SerializedName("user_info") val userInfo: UserInfo? = null, + val auth: String = "0", + val status: String = "" +) + +data class UserInfo( + val username: String = "", + @SerializedName("auth") val auth: Int = 0, + val status: String = "", + @SerializedName("created_at") val createdAt: String = "", + @SerializedName("exp_date") val expDate: String = "", + @SerializedName("is_trial") val isTrial: String = "0", + @SerializedName("max_connections") val maxConnections: String = "0", + @SerializedName("allowed_output_formats") val allowedFormats: List = emptyList() +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt new file mode 100644 index 0000000..8a29920 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt @@ -0,0 +1,55 @@ +package ma.tkgraph.xtreamtv.data.model + +import android.util.Base64 +import com.google.gson.annotations.SerializedName +import java.nio.charset.StandardCharsets + +data class EpgProgramDto( + @SerializedName("id") val id: String? = null, + @SerializedName("epg_id") val epgId: String? = null, + @SerializedName("title") val rawTitle: String? = null, + @SerializedName("description") val rawDescription: String? = null, + @SerializedName("start") val start: String? = null, + @SerializedName("end") val end: String? = null, + @SerializedName("start_timestamp") val startTimestamp: String? = null, + @SerializedName("stop_timestamp") val stopTimestamp: String? = null, + @SerializedName("has_archive") val hasArchive: Int? = null, + @SerializedName("now_playing") val nowPlaying: Int? = null, + @SerializedName("lang") val lang: String? = null +) { + fun titleText(): String { + return decodeMaybeBase64(rawTitle).ifBlank { "" } + } + + fun startMillis(): Long? { + startTimestamp?.toLongOrNull()?.let { return it * 1000L } + return null + } + + fun stopMillis(): Long? { + stopTimestamp?.toLongOrNull()?.let { return it * 1000L } + return null + } + + /** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */ + fun durationMinutes(): Int { + // Depuis les timestamps Unix (en secondes) + val startSec = startTimestamp?.toLongOrNull() ?: return 120 + val stopSec = stopTimestamp?.toLongOrNull() ?: return 120 + return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720) + } +} + +/** Décode si Base64, sinon retourne tel quel. */ +private fun decodeMaybeBase64(value: String?): String { + val text = value.orEmpty().trim() + if (text.isBlank()) return "" + return runCatching { + val decoded = Base64.decode(text, Base64.DEFAULT) + String(decoded, StandardCharsets.UTF_8).trim() + }.getOrNull() + ?.takeIf { decoded -> + decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() } + } + ?: text +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt new file mode 100644 index 0000000..54f2e21 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt @@ -0,0 +1,11 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +data class EpgShortResponse( + @SerializedName("epg_listings") val listings: List? = emptyList() +) + +data class EpgFullResponse( + @SerializedName("epg_listings") val listings: List? = emptyList() +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt new file mode 100644 index 0000000..08235a6 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt @@ -0,0 +1,12 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour une catégorie Live. + */ +data class LiveCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt new file mode 100644 index 0000000..7f67327 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt @@ -0,0 +1,24 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour un flux Live (chaîne TV). + */ +data class LiveStreamDto( + val num: Int = 0, + val name: String = "", + @SerializedName("stream_id") val streamId: Int = 0, + @SerializedName("stream_type") val streamType: String = "", + @SerializedName("stream_icon") val streamIcon: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("epg_channel_id") val epgChannelId: String? = null, + @SerializedName("tv_archive") val tvArchive: Int = 0, + @SerializedName("has_archive") val hasArchive: Boolean = false, + @SerializedName("container_extension") val containerExtension: String = "m3u8", + val added: String = "", + @SerializedName("custom_sid") val customSid: String? = null, + @SerializedName("tv_archive_duration") val archiveDuration: Int = 0, + /** URL directe du flux (utilisé en mode M3U). */ + val directUrl: String = "" +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt new file mode 100644 index 0000000..058acbc --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour une série. + */ +data class SeriesDto( + val num: Int = 0, + val name: String = "", + @SerializedName("series_id") val seriesId: Int = 0, + val cover: String? = null, + val plot: String? = null, + val cast: String? = null, + val rating: String? = null, + val genre: String? = null, + val year: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("last_modified") val lastModified: String? = null +) + +data class SeriesCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) + +/** + * Détail d'une série (saisons + épisodes). + */ +data class SeriesInfoDto( + val seasons: List = emptyList(), + val episodes: Map> = emptyMap() +) + +data class SeasonDto( + val id: Int = 0, + @SerializedName("season_number") val seasonNumber: Int = 0, + val name: String = "" +) + +data class EpisodeDto( + val id: Int = 0, + @SerializedName("episode_num") val episodeNum: Int = 0, + val title: String? = null, + val name: String? = null, + @SerializedName("episode_name") val episodeName: String? = null, + @SerializedName("container_extension") val containerExtension: String = "mp4", + val info: EpisodeInfoDto? = null +) + +data class EpisodeInfoDto( + @SerializedName("movie_image") val movieImage: String? = null, + val plot: String? = null, + val duration: String? = null, + val rating: String? = null +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt new file mode 100644 index 0000000..14dac3f --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt @@ -0,0 +1,16 @@ +package ma.tkgraph.xtreamtv.data.model + +/** + * État générique pour tous les écrans. + * Usage : ViewModel expose `StateFlow>` + */ +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error( + val message: String, + val retry: Boolean = true, + val exception: XtreamException? = null + ) : UiState +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt new file mode 100644 index 0000000..9e2b4b8 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt @@ -0,0 +1,29 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour un film VOD. + */ +data class VodDto( + val num: Int = 0, + val name: String = "", + @SerializedName("stream_id") val streamId: Int = 0, + @SerializedName("stream_type") val streamType: String = "movie", + @SerializedName("stream_icon") val streamIcon: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("container_extension") val containerExtension: String = "mp4", + val rating: String? = null, + val added: String = "", + val description: String? = null, + val year: String? = null, + val genre: String? = null, + val director: String? = null, + val cast: String? = null +) + +data class VodCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt new file mode 100644 index 0000000..a9205b7 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt @@ -0,0 +1,23 @@ +package ma.tkgraph.xtreamtv.data.model + +/** + * Type de source : serveur Xtream ou fichier M3U. + */ +enum class SourceType { XTREAM, M3U } + +/** + * Configuration de connexion (Xtream ou M3U). + */ +data class XtreamConfig( + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val m3uUrl: String = "", + val sourceType: SourceType = SourceType.XTREAM +) { + /** Vrai si une configuration valide est présente (quel que soit le mode). */ + val isValid: Boolean get() = when (sourceType) { + SourceType.XTREAM -> serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank() + SourceType.M3U -> m3uUrl.isNotBlank() + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt new file mode 100644 index 0000000..47ad6c1 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt @@ -0,0 +1,25 @@ +package ma.tkgraph.xtreamtv.data.model + +import java.io.IOException + +/** + * Hiérarchie d'exceptions Xtream. + * Étend IOException pour être compatible avec OkHttp Interceptor. + */ +sealed class XtreamException( + message: String, + cause: Throwable? = null +) : IOException(message, cause) { + + class NetworkException(message: String) : XtreamException(message) + + class TimeoutException(message: String) : XtreamException(message) + + class AuthException(message: String) : XtreamException(message) + + class ServerException(val code: Int, message: String) : XtreamException(message) + + class ParseException(message: String, cause: Throwable) : XtreamException(message, cause) + + class StreamUnavailableException(message: String) : XtreamException(message) +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt new file mode 100644 index 0000000..4896f0a --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt @@ -0,0 +1,98 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.data.model.AuthResponse +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.EpgFullResponse +import ma.tkgraph.xtreamtv.data.model.EpgShortResponse +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Interface Retrofit pour l'API Xtream Codes. + * L'URL de base est définie dynamiquement par le Repository. + */ +interface XtreamApi { + + @GET("/player_api.php") + suspend fun authenticate( + @Query("username") username: String, + @Query("password") password: String + ): AuthResponse + + @GET("/player_api.php") + suspend fun getLiveCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_live_categories" + ): List + + @GET("/player_api.php") + suspend fun getLiveStreams( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_live_streams", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getShortEpg( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_short_epg", + @Query("stream_id") streamId: String, + @Query("limit") limit: Int = 8 + ): EpgShortResponse + + @GET("/player_api.php") + suspend fun getFullEpg( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_simple_data_table", + @Query("stream_id") streamId: String + ): EpgFullResponse + + @GET("/player_api.php") + suspend fun getVodCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_vod_categories" + ): List + + @GET("/player_api.php") + suspend fun getVodStreams( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_vod_streams", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getSeriesCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series_categories" + ): List + + @GET("/player_api.php") + suspend fun getSeries( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getSeriesInfo( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series_info", + @Query("series_id") seriesId: Int + ): SeriesInfoDto +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt new file mode 100644 index 0000000..491c620 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt @@ -0,0 +1,56 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.XtreamApplication +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Intercepteur OkHttp qui réécrit l'URL de base de Retrofit + * avec l'URL du serveur Xtream stockée dans SecureStorage. + * + * Retrofit est initialisé avec "http://localhost" comme placeholder. + * Cet interceptor remplace "localhost" par le vrai serveur Xtream. + */ +class XtreamBaseUrlInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials() + + if (config == null) { + // Pas de credentials → laisser l'URL telle quelle (echouera gracieusement) + return chain.proceed(originalRequest) + } + + val serverHost = extractHost(config.serverUrl) + val newUrl = originalRequest.url.newBuilder() + .host(serverHost) + .port(extractPort(config.serverUrl)) + .build() + + val newRequest = originalRequest.newBuilder() + .url(newUrl) + .build() + + return chain.proceed(newRequest) + } + + private fun extractHost(url: String): String { + return url + .removePrefix("http://") + .removePrefix("https://") + .substringBefore(":") + .substringBefore("/") + } + + private fun extractPort(url: String): Int { + val afterProtocol = url + .removePrefix("http://") + .removePrefix("https://") + return if (afterProtocol.contains(":")) { + afterProtocol.substringAfter(":").substringBefore("/").toIntOrNull() ?: 80 + } else { + if (url.startsWith("https")) 443 else 80 + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt new file mode 100644 index 0000000..682eeb7 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt @@ -0,0 +1,51 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.data.model.XtreamException +import okhttp3.Interceptor +import okhttp3.Response +import timber.log.Timber +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Intercepteur OkHttp qui transforme les erreurs réseau + * en exceptions typées pour un traitement uniforme. + */ +class XtreamErrorInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + return try { + val response = chain.proceed(request) + + when { + response.code == 401 || response.code == 403 -> + throw XtreamException.AuthException("Authentication failed") + response.code in 400..499 -> + throw XtreamException.ServerException( + response.code, "Client error: ${response.code}" + ) + response.code in 500..599 -> + throw XtreamException.ServerException( + response.code, "Server error: ${response.code}" + ) + } + + response + } catch (e: SocketTimeoutException) { + Timber.w("Network timeout: ${request.url}") + throw XtreamException.TimeoutException("Connection timed out") + } catch (e: ConnectException) { + Timber.w("Connection refused: ${request.url}") + throw XtreamException.NetworkException("Cannot connect to server") + } catch (e: UnknownHostException) { + Timber.w("DNS resolution failed for: ${request.url}") + throw XtreamException.NetworkException("DNS resolution failed") + } catch (e: XtreamException) { + // Déjà typé, on relance tel quel + throw e + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt new file mode 100644 index 0000000..3f86917 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt @@ -0,0 +1,89 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.AuthResponse +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.model.XtreamException +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +/** + * Gère l'authentification et la session utilisateur. + */ +class AuthRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + + /** + * Tente de se connecter au serveur Xtream. + */ + suspend fun login(serverUrl: String, username: String, password: String): UiState { + return try { + Timber.i("Attempting login for user: %s", username) + val response = api.authenticate(username, password) + + when { + response.auth != "1" -> { + Timber.w("Login failed: auth field is not 1") + UiState.Error("Invalid credentials or server.", retry = true) + } + response.status != "Active" -> { + Timber.w("Login failed: account status is %s", response.status) + UiState.Error( + "Account is not active. Status: ${response.status}", + retry = false + ) + } + else -> { + storage.saveConfig(XtreamConfig( + sourceType = SourceType.XTREAM, + serverUrl = serverUrl, + username = username, + password = password + )) + val info = UserInfo(username = username) + Timber.i("Login successful for: %s", username) + UiState.Success(info) + } + } + } catch (e: XtreamException.TimeoutException) { + UiState.Error("Server is not responding. Try again later.", retry = true, exception = e) + } catch (e: XtreamException.NetworkException) { + UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e) + } catch (e: XtreamException.AuthException) { + UiState.Error("Invalid credentials or server.", retry = true, exception = e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error during login") + UiState.Error("Connection failed. Please try again.", retry = true) + } + } + + /** Sauvegarde une config M3U. */ + fun saveM3uConfig(m3uUrl: String) { + storage.saveConfig(XtreamConfig( + sourceType = SourceType.M3U, + m3uUrl = m3uUrl + )) + } + + /** Supprime la session et vide le cache M3U. */ + fun logout() { + storage.clearAll() + Timber.i("User logged out") + } + + fun hasSession(): Boolean = storage.hasConfig() + fun getSavedConfig(): XtreamConfig? = storage.getConfig() +} + +/** Informations utilisateur retournées après connexion. */ +data class UserInfo( + val username: String, + val status: String = "Active", + val expDate: String? = null, + val maxConnections: String = "1", + val isTrial: Boolean = false +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt new file mode 100644 index 0000000..ab2a467 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt @@ -0,0 +1,41 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +class EpgRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + private fun config(): XtreamConfig? = storage.getCredentials() + + suspend fun getShortEpg(streamId: Int): UiState> { + return try { + val c = config() ?: return UiState.Error("Not logged in") + val response = api.getShortEpg(c.username, c.password, streamId = streamId.toString(), limit = 8) + val programs = response.listings.orEmpty() + .map { it.copy(rawTitle = it.rawTitle) } // titleText() sera appelé côté UI + Timber.i("EPG short %d: %d programs", streamId, programs.size) + UiState.Success(programs) + } catch (e: Exception) { + Timber.w(e, "EPG short failed stream=%d", streamId) + UiState.Success(emptyList()) + } + } + + suspend fun getFullGuide(streamId: Int): UiState> { + return try { + val c = config() ?: return UiState.Error("Not logged in") + val response = api.getFullEpg(c.username, c.password, streamId = streamId.toString()) + val programs = response.listings.orEmpty() + UiState.Success(programs) + } catch (e: Exception) { + Timber.w(e, "EPG full failed stream=%d", streamId) + UiState.Success(emptyList()) + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt new file mode 100644 index 0000000..17113d9 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt @@ -0,0 +1,26 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamException +import timber.log.Timber + +/** + * Helper pour enrober les appels réseau dans un bloc try-catch + * standardisé qui retourne UiState.Error pour toute exception. + */ +suspend fun catchErrors(block: suspend () -> UiState): UiState { + return try { + block() + } catch (e: XtreamException.TimeoutException) { + UiState.Error("Server is not responding. Try again later.", retry = true, exception = e) + } catch (e: XtreamException.NetworkException) { + UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e) + } catch (e: XtreamException.AuthException) { + UiState.Error("Authentication failed.", retry = true, exception = e) + } catch (e: XtreamException.ServerException) { + UiState.Error("Server error (${e.code}). Contact your provider.", retry = true, exception = e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error") + UiState.Error("An unexpected error occurred.", retry = true) + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt new file mode 100644 index 0000000..d4de72b --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt @@ -0,0 +1,65 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Gère les favoris pour Live, VOD et Séries. + * Délègue le stockage à SecureStorage. + */ +class FavoritesRepository(private val storage: SecureStorage) { + + // ─── Live Favorites ─── + fun getFavoriteLiveIds(): Set = storage.getFavoriteLiveIds() + + fun toggleFavoriteLive(streamId: Int): Boolean { + val current = getFavoriteLiveIds().toMutableSet() + return if (streamId in current) { + current.remove(streamId) + storage.setFavoriteLiveIds(current) + false // removed + } else { + current.add(streamId) + storage.setFavoriteLiveIds(current) + true // added + } + } + + fun isFavoriteLive(streamId: Int): Boolean = streamId in getFavoriteLiveIds() + + // ─── VOD Favorites ─── + fun getFavoriteVodIds(): Set = storage.getFavoriteVodIds() + + fun toggleFavoriteVod(streamId: Int): Boolean { + val current = getFavoriteVodIds().toMutableSet() + return if (streamId in current) { + current.remove(streamId) + storage.setFavoriteVodIds(current) + false + } else { + current.add(streamId) + storage.setFavoriteVodIds(current) + true + } + } + + // ─── Series Favorites ─── + fun getFavoriteSeriesIds(): Set = storage.getFavoriteSeriesIds() + + fun toggleFavoriteSeries(seriesId: Int): Boolean { + val current = getFavoriteSeriesIds().toMutableSet() + return if (seriesId in current) { + current.remove(seriesId) + storage.setFavoriteSeriesIds(current) + false + } else { + current.add(seriesId) + storage.setFavoriteSeriesIds(current) + true + } + } + + // ─── Pinned Channel ─── + fun getPinnedChannelId(): Int? = storage.getPinnedChannelId() + + fun setPinnedChannelId(streamId: Int?) = storage.setPinnedChannelId(streamId) +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt new file mode 100644 index 0000000..f73a5d4 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt @@ -0,0 +1,60 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import timber.log.Timber + +/** + * Repository pour les flux Live TV. + * Délègue vers XtreamApi ou M3uParser selon la source configurée. + */ +class LiveRepository( + private val api: XtreamApi, + private val storage: SecureStorage, + private val m3uRepository: M3uRepository +) { + private fun requireConfig(): XtreamConfig? = storage.getConfig() + + suspend fun getCategories(): UiState> { + val config = requireConfig() ?: return UiState.Error("Not logged in") + return when (config.sourceType) { + SourceType.XTREAM -> getXtreamCategories(config) + SourceType.M3U -> m3uRepository.getCategories() + } + } + + suspend fun getStreams(categoryId: Int? = null): UiState> { + val config = requireConfig() ?: return UiState.Error("Not logged in") + return when (config.sourceType) { + SourceType.XTREAM -> getXtreamStreams(config, categoryId) + SourceType.M3U -> m3uRepository.getStreams(categoryId) + } + } + + private suspend fun getXtreamCategories(config: XtreamConfig): UiState> { + return try { + val categories = api.getLiveCategories(config.username, config.password) + val sorted = CategorySortUtils.sortLiveCategories(categories) + UiState.Success(sorted) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch Xtream categories") + UiState.Error("Erreur de chargement des catégories") + } + } + + private suspend fun getXtreamStreams(config: XtreamConfig, categoryId: Int?): UiState> { + return try { + val streams = api.getLiveStreams(config.username, config.password, categoryId = categoryId) + UiState.Success(streams) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch Xtream streams") + UiState.Error("Erreur de chargement des chaînes") + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt new file mode 100644 index 0000000..141ec64 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt @@ -0,0 +1,145 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Parse un fichier M3U (Xtream-compatible) en listes de [LiveStreamDto]. + * + * Format supporté : + * #EXTM3U + * #EXTINF:-1 tvg-id="…" tvg-name="…" tvg-logo="…" group-title="Catégorie",Nom de la chaîne + * http://url-du-flux.m3u8 + * + * Chaque chaîne reçoit un streamId = hash de son URL (pour les favoris/pin). + */ +object M3uParser { + + private const val TIMEOUT_SECONDS = 15L + + private val client = OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .followRedirects(true) + .build() + + /** + * Télécharge et parse une playlist M3U. + * @param url URL distante ou locale du fichier M3U + * @return Pair (catégories, streams) ou null si échec + */ + suspend fun fetchAndParse(url: String): Pair, List>? { + return try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.w("M3U fetch failed: HTTP ${response.code}") + return null + } + val body = response.body?.string() ?: return null + parse(body) + } catch (e: Exception) { + Timber.e(e, "M3U fetch error") + null + } + } + + /** + * Parse le contenu textuel d'un fichier M3U. + */ + fun parse(content: String): Pair, List> { + val categoryMap = mutableMapOf() // nom → id (hash) + val categories = mutableListOf() + val streams = mutableListOf() + + val lines = content.lines() + var i = 0 + var streamIdCounter = 1 + + while (i < lines.size) { + val line = lines[i].trim() + if (line.startsWith("#EXTINF:")) { + // Extraire les attributs de la ligne EXTINF + val attrs = parseExtInfAttributes(line) + + // L'URL est sur la ligne suivante (si elle existe et ne commence pas par #) + val urlLine = if (i + 1 < lines.size) lines[i + 1].trim() else "" + val streamUrl = if (urlLine.isNotBlank() && !urlLine.startsWith("#")) urlLine else "" + + if (streamUrl.isNotBlank()) { + val name = attrs["name"] ?: "Chaîne $streamIdCounter" + val logo = attrs["logo"] + val groupTitle = attrs["group-title"] + + // Générer un ID stable à partir de l'URL + val stableId = streamUrl.hashCode() + // Fallback si hash trop petit/négatif + val streamId = if (stableId > 0) stableId else streamIdCounter + + // Gérer les catégories + if (groupTitle != null && groupTitle !in categoryMap) { + val catId = categoryMap.size + 1 + categoryMap[groupTitle] = catId + categories.add(LiveCategoryDto( + categoryId = catId.toString(), + categoryName = groupTitle + )) + } + + val catIdStr = if (groupTitle != null) categoryMap[groupTitle]?.toString() ?: "" else "" + + streams.add(LiveStreamDto( + num = streamIdCounter, + name = name, + streamId = streamId, + streamType = "live", + streamIcon = logo, + categoryId = catIdStr, + containerExtension = streamUrl.substringAfterLast('.', "m3u8"), + directUrl = streamUrl + )) + streamIdCounter++ + i++ // Sauter la ligne URL + } + } + i++ + } + + Timber.i("M3U parsed: ${categories.size} categories, ${streams.size} streams") + return Pair(categories, streams) + } + + /** + * Extrait les attributs d'une ligne #EXTINF. + * Exemple : #EXTINF:-1 tvg-logo="https://…" group-title="Sports",Nom + */ + private fun parseExtInfAttributes(line: String): Map { + val attrs = mutableMapOf() + + // Extraire les paires clé="valeur" + val regex = Regex("""(\w[\w-]*)\s*=\s*"([^"]*)"""") + for (match in regex.findAll(line)) { + attrs[match.groupValues[1]] = match.groupValues[2] + } + + // Extraire le nom de la chaîne (après la dernière virgule) + val commaIdx = line.lastIndexOf(',') + if (commaIdx >= 0 && commaIdx + 1 < line.length) { + val name = line.substring(commaIdx + 1).trim() + if (name.isNotBlank()) { + attrs["name"] = name + } + } + + // Fallback sur tvg-name si le nom n'a pas été trouvé après la virgule + if (!attrs.containsKey("name") && attrs.containsKey("tvg-name")) { + attrs["name"] = attrs["tvg-name"]!! + } + + return attrs + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt new file mode 100644 index 0000000..ab4b648 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt @@ -0,0 +1,54 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +/** + * Repository pour les playlists M3U. + */ +class M3uRepository(private val storage: SecureStorage) { + + private var cachedCategories: List = emptyList() + private var cachedStreams: List = emptyList() + + /** Récupère les catégories depuis le M3U. */ + suspend fun getCategories(): UiState> { + val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré") + return UiState.Success(data.first) + } + + /** Récupère les streams depuis le M3U. */ + suspend fun getStreams(categoryId: Int? = null): UiState> { + val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré") + val streams = if (categoryId != null) { + data.second.filter { it.categoryId == categoryId.toString() } + } else { + data.second + } + return UiState.Success(streams) + } + + /** Invalide le cache M3U. */ + fun invalidateCache() { + cachedCategories = emptyList() + cachedStreams = emptyList() + } + + private suspend fun fetchIfNeeded(): Pair, List>? { + if (cachedStreams.isNotEmpty()) { + return Pair(cachedCategories, cachedStreams) + } + val config = storage.getConfig() ?: return null + if (config.m3uUrl.isBlank()) return null + + Timber.i("Fetching M3U from: ${config.m3uUrl}") + val result = M3uParser.fetchAndParse(config.m3uUrl) ?: return null + + cachedCategories = result.first + cachedStreams = result.second + return result + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt new file mode 100644 index 0000000..182ffcd --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt @@ -0,0 +1,49 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import timber.log.Timber + +/** + * Repository pour les séries. + */ +class SeriesRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + private fun requireConfig(): XtreamConfig? = storage.getCredentials() + + suspend fun getCategories(): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val cats = api.getSeriesCategories(config.username, config.password) + val sorted = CategorySortUtils.sortSeriesCategories(cats) + UiState.Success(sorted) + } + + suspend fun getSeriesList(categoryId: Int? = null): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val series = api.getSeries(config.username, config.password, categoryId = categoryId) + UiState.Success(series) + } + + suspend fun getSeriesInfo(seriesId: Int): UiState { + return try { + val config = requireConfig() ?: return UiState.Error("Not logged in") + val info = api.getSeriesInfo( + username = config.username, + password = config.password, + seriesId = seriesId + ) + UiState.Success(info) + } catch (e: Exception) { + Timber.e(e, "Failed to load series info for %d", seriesId) + UiState.Error("Failed to load series details.") + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt new file mode 100644 index 0000000..b5aac08 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt @@ -0,0 +1,40 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Stocke et lit les compteurs d'utilisation pour trier l'IPTV comme un être humain + * qui ouvre toujours les mêmes chaînes, mais avec une télécommande. + */ +class UsageRepository(private val storage: SecureStorage) { + + fun recordLiveCategory(categoryId: Int?) = record(BUCKET_LIVE_CATEGORY, categoryId) + fun recordVodCategory(categoryId: Int?) = record(BUCKET_VOD_CATEGORY, categoryId) + fun recordSeriesCategory(categoryId: Int?) = record(BUCKET_SERIES_CATEGORY, categoryId) + + fun recordLiveStream(streamId: Int) = record(BUCKET_LIVE_STREAM, streamId) + fun recordVodItem(streamId: Int) = record(BUCKET_VOD_ITEM, streamId) + fun recordSeriesItem(seriesId: Int) = record(BUCKET_SERIES_ITEM, seriesId) + + fun liveCategoryCounts(): Map = storage.getUsageCounters(BUCKET_LIVE_CATEGORY) + fun vodCategoryCounts(): Map = storage.getUsageCounters(BUCKET_VOD_CATEGORY) + fun seriesCategoryCounts(): Map = storage.getUsageCounters(BUCKET_SERIES_CATEGORY) + + fun liveStreamCounts(): Map = storage.getUsageCounters(BUCKET_LIVE_STREAM) + fun vodItemCounts(): Map = storage.getUsageCounters(BUCKET_VOD_ITEM) + fun seriesItemCounts(): Map = storage.getUsageCounters(BUCKET_SERIES_ITEM) + + private fun record(bucket: String, id: Int?) { + if (id == null || id < 0) return + storage.incrementUsageCounter(bucket, id.toString()) + } + + companion object { + private const val BUCKET_LIVE_CATEGORY = "live_category" + private const val BUCKET_VOD_CATEGORY = "vod_category" + private const val BUCKET_SERIES_CATEGORY = "series_category" + private const val BUCKET_LIVE_STREAM = "live_stream" + private const val BUCKET_VOD_ITEM = "vod_item" + private const val BUCKET_SERIES_ITEM = "series_item" + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt new file mode 100644 index 0000000..75f3134 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt @@ -0,0 +1,33 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import timber.log.Timber + +/** + * Repository pour les VOD (films). + */ +class VodRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + private fun requireConfig(): XtreamConfig? = storage.getCredentials() + + suspend fun getCategories(): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val cats = api.getVodCategories(config.username, config.password) + val sorted = CategorySortUtils.sortVodCategories(cats) + UiState.Success(sorted) + } + + suspend fun getMovies(categoryId: Int? = null): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val movies = api.getVodStreams(config.username, config.password, categoryId = categoryId) + UiState.Success(movies) + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt new file mode 100644 index 0000000..cb1eb3e --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt @@ -0,0 +1,134 @@ +package ma.tkgraph.xtreamtv.data.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.XtreamConfig + +/** + * Stockage sécurisé des credentials avec EncryptedSharedPreferences. + * + * - Keys : AES-256 SIV + * - Values : AES-256 GCM + * - MasterKey générée via AndroidKeyStore + */ +class SecureStorage(context: Context) { + + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // ─── Config complète (Xtream ou M3U) ─── + + fun saveConfig(config: XtreamConfig) { + prefs.edit() + .putString(KEY_SOURCE_TYPE, config.sourceType.name) + .putString(KEY_SERVER, config.serverUrl) + .putString(KEY_USERNAME, config.username) + .putString(KEY_PASSWORD, config.password) + .putString(KEY_M3U_URL, config.m3uUrl) + .apply() + } + + fun getConfig(): XtreamConfig? { + // Essayer le nouveau format (avec sourceType) + val sourceTypeStr = prefs.getString(KEY_SOURCE_TYPE, null) + if (sourceTypeStr != null) { + val sourceType = try { SourceType.valueOf(sourceTypeStr) } catch (_: Exception) { SourceType.XTREAM } + return when (sourceType) { + SourceType.XTREAM -> { + val server = prefs.getString(KEY_SERVER, null) ?: return null + val username = prefs.getString(KEY_USERNAME, null) ?: return null + val password = prefs.getString(KEY_PASSWORD, null) ?: return null + XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password) + } + SourceType.M3U -> { + val m3uUrl = prefs.getString(KEY_M3U_URL, null) ?: return null + XtreamConfig(sourceType = SourceType.M3U, m3uUrl = m3uUrl) + } + } + } + // Fallback : ancien format (sans sourceType, session Xtream directe) + val server = prefs.getString(KEY_SERVER, null) ?: return null + val username = prefs.getString(KEY_USERNAME, null) ?: return null + val password = prefs.getString(KEY_PASSWORD, null) ?: return null + return XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password) + } + + /** Retourne la config en ignorant le type (pour les migrations). */ + fun getCredentials(): XtreamConfig? = getConfig() + + fun hasConfig(): Boolean = prefs.contains(KEY_SOURCE_TYPE) + + fun clearAll() { + prefs.edit().clear().apply() + } + + // ─── Favorites (Live) ─── + + fun getFavoriteLiveIds(): Set = + prefs.getStringSet(KEY_FAV_LIVE, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteLiveIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_LIVE, ids.map { it.toString() }.toSet()).apply() + } + + // ─── Favorites (VOD) ─── + + fun getFavoriteVodIds(): Set = + prefs.getStringSet(KEY_FAV_VOD, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteVodIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_VOD, ids.map { it.toString() }.toSet()).apply() + } + + // ─── Favorites (Series) ─── + + fun getFavoriteSeriesIds(): Set = + prefs.getStringSet(KEY_FAV_SERIES, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteSeriesIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_SERIES, ids.map { it.toString() }.toSet()).apply() + } + + // ─── Pinned Channel ─── + + fun getPinnedChannelId(): Int? { + val id = prefs.getString(KEY_PINNED, null) ?: return null + return id.toIntOrNull() + } + + fun setPinnedChannelId(streamId: Int?) { + prefs.edit().putString(KEY_PINNED, streamId?.toString()).apply() + } + + companion object { + private const val PREFS_NAME = "imedia_secure_prefs" + + private const val KEY_SOURCE_TYPE = "source_type" + private const val KEY_SERVER = "server_url" + private const val KEY_USERNAME = "username" + private const val KEY_PASSWORD = "password" + private const val KEY_M3U_URL = "m3u_url" + private const val KEY_FAV_LIVE = "favorite_live" + private const val KEY_FAV_VOD = "favorite_vod" + private const val KEY_FAV_SERIES = "favorite_series" + private const val KEY_PINNED = "pinned_channel" + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt new file mode 100644 index 0000000..5ab9701 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Modifier qui ajoute un contour lumineux visible quand l'élément ou un de ses enfants a le focus. + * Idéal pour la navigation télécommande Android TV. + * + * @param focusedBorderColor Couleur du contour quand focusé + * @param unfocusedBorderColor Couleur du contour quand pas focusé (transparent par défaut) + * @param borderWidth Épaisseur du contour + * @param cornerRadius Arrondi des coins + */ +fun Modifier.tvFocusHighlight( + focusedBorderColor: Color = Color(0xFFE50914), + unfocusedBorderColor: Color = Color.Transparent, + borderWidth: Dp = 3.dp, + cornerRadius: Dp = 12.dp +): Modifier = composed { + var isFocused by remember { mutableStateOf(false) } + + val animatedAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(durationMillis = 150) + ) + + this + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .then( + if (isFocused) { + Modifier.border( + width = borderWidth, + color = focusedBorderColor.copy(alpha = animatedAlpha), + shape = RoundedCornerShape(cornerRadius) + ) + } else { + Modifier.border( + width = borderWidth, + color = unfocusedBorderColor, + shape = RoundedCornerShape(cornerRadius) + ) + } + ) +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt new file mode 100644 index 0000000..567bd60 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt @@ -0,0 +1,658 @@ +package ma.tkgraph.xtreamtv.ui.components + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key as ComposeKey +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Commande envoyée depuis PlayerScreen vers le mini lecteur YouTube. + * Le nonce force Compose à rejouer la commande même si le même bouton est pressé plusieurs fois. + */ +data class YoutubeRemoteCommand( + val type: YoutubeCommandType, + val nonce: Long = System.nanoTime() +) + +enum class YoutubeCommandType { + TogglePlayPause, + SeekBackward, + SeekForward +} + +private enum class YoutubeLoadMode { + Embed, + Site +} + +/** + * Mémoire légère de session : garde le dernier lien saisi tant que le processus + * de l'application reste ouvert. Rien n'est écrit sur disque. + */ +private object YoutubeMiniPlayerSessionMemory { + var lastInputText: String = DEFAULT_YOUTUBE_WATCH_PREFIX +} + +private data class YoutubeEmbedTarget( + val normalizedUrl: String, + val videoId: String?, + val playlistId: String?, + val channelOrSiteUrl: String? +) { + val canUseEmbed: Boolean = videoId != null || playlistId != null +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun YoutubeMiniPlayer( + modifier: Modifier = Modifier, + remoteCommand: YoutubeRemoteCommand? = null, + onClose: () -> Unit +) { + val inputFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val loadButtonFocusRequester = remember { FocusRequester() } + val minimizeButtonFocusRequester = remember { FocusRequester() } + val closeButtonFocusRequester = remember { FocusRequester() } + val minimizedFocusRequester = remember { FocusRequester() } + var isInputFocused by remember { mutableStateOf(false) } + var inputUrl by rememberSaveable(stateSaver = TextFieldValue.Saver) { + val rememberedInput = YoutubeMiniPlayerSessionMemory.lastInputText + .takeIf { it.isNotBlank() } + ?: DEFAULT_YOUTUBE_WATCH_PREFIX + mutableStateOf( + TextFieldValue( + text = rememberedInput, + selection = TextRange(rememberedInput.length) + ) + ) + } + var loadedUrl by rememberSaveable { mutableStateOf("") } + var loadModeName by rememberSaveable { mutableStateOf(YoutubeLoadMode.Embed.name) } + var status by remember { mutableStateOf("V8 focus : ajoutez le code puis Charger") } + var isMinimized by rememberSaveable { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + val loadMode = remember(loadModeName) { + runCatching { YoutubeLoadMode.valueOf(loadModeName) }.getOrDefault(YoutubeLoadMode.Embed) + } + val target = remember(loadedUrl) { parseYoutubeTarget(loadedUrl) } + + fun exitInputAndFocusLoadButton() { + focusManager.clearFocus(force = true) + runCatching { loadButtonFocusRequester.requestFocus() } + status = "Champ quitté : OK sur Charger" + } + + DisposableEffect(Unit) { + onDispose { + webView?.stopLoading() + webView?.destroy() + webView = null + } + } + + BackHandler(enabled = !isMinimized) { + if (isInputFocused) { + exitInputAndFocusLoadButton() + } else { + onClose() + } + } + + BackHandler(enabled = isMinimized) { + onClose() + } + + fun sendCommand(type: YoutubeCommandType) { + val js = when (type) { + YoutubeCommandType.TogglePlayPause -> "if(window.togglePlay){togglePlay();}" + YoutubeCommandType.SeekBackward -> "if(window.seekBy){seekBy(-10);}" + YoutubeCommandType.SeekForward -> "if(window.seekBy){seekBy(10);}" + } + webView?.evaluateJavascript(js, null) + } + + fun loadUrl(mode: YoutubeLoadMode) { + val normalized = normalizeYoutubeUrl(inputUrl.text) + YoutubeMiniPlayerSessionMemory.lastInputText = inputUrl.text + loadedUrl = normalized + loadModeName = mode.name + status = when { + normalized.isBlank() -> "Ajoutez le code vidéo après watch?v=" + mode == YoutubeLoadMode.Site -> "Chargement du site YouTube" + else -> "Chargement du player intégré" + } + } + + fun restoreFromMinimized() { + isMinimized = false + status = "Mode normal : OK = Play/Pause" + } + + LaunchedEffect(remoteCommand) { + remoteCommand?.let { sendCommand(it.type) } + } + + LaunchedEffect(Unit) { + runCatching { inputFocusRequester.requestFocus() } + } + + LaunchedEffect(isMinimized) { + if (isMinimized) { + status = "Mode minimisé : OK pour agrandir" + runCatching { minimizedFocusRequester.requestFocus() } + } else { + runCatching { inputFocusRequester.requestFocus() } + } + } + + val surfaceModifier = modifier.then( + if (isMinimized) { + Modifier + .width(220.dp) + .alpha(0.30f) + .focusRequester(minimizedFocusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && (event.key == Key.DirectionCenter || event.key == Key.Enter)) { + restoreFromMinimized() + true + } else if (event.type == KeyEventType.KeyUp && event.key == Key.Back) { + onClose() + true + } else { + false + } + } + } else { + Modifier + .width(520.dp) + .onPreviewKeyEvent { event -> + when { + // Filet de sécurité au niveau du conteneur. + isInputFocused && (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && + (event.key == Key.DirectionRight || event.key == Key.DirectionDown || event.key == Key.Tab || event.key == Key.Back) -> { + if (event.type == KeyEventType.KeyDown) { + exitInputAndFocusLoadButton() + } + true + } + + // OK depuis le champ charge directement, utile sur les Mi Box qui gardent le focus texte. + isInputFocused && event.type == KeyEventType.KeyDown && + (event.key == Key.DirectionCenter || event.key == Key.Enter) -> { + loadUrl(YoutubeLoadMode.Embed) + exitInputAndFocusLoadButton() + true + } + + event.type == KeyEventType.KeyUp && event.key == Key.Back -> { + onClose() + true + } + + else -> false + } + } + } + ) + + Surface( + modifier = surfaceModifier, + color = Color.Black.copy(alpha = if (isMinimized) 0.34f else 0.88f), + shape = RoundedCornerShape(if (isMinimized) 14.dp else 18.dp), + shadowElevation = if (isMinimized) 2.dp else 10.dp + ) { + Column( + modifier = Modifier + .border( + 1.dp, + Color(0xFF00D5FF).copy(alpha = if (isMinimized) 0.16f else 0.45f), + RoundedCornerShape(if (isMinimized) 14.dp else 18.dp) + ) + .padding(if (isMinimized) 5.dp else 12.dp), + verticalArrangement = Arrangement.spacedBy(if (isMinimized) 0.dp else 8.dp) + ) { + if (!isMinimized) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "YouTube discret · V8", + color = Color.White, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "TV muette · OK Play/Pause · Retour fermer", + color = Color(0xFFB8C7D1), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton( + onClick = onClose, + modifier = Modifier.focusRequester(closeButtonFocusRequester) + ) { + Text("Fermer", color = Color.White) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = inputUrl, + onValueChange = { + inputUrl = it + YoutubeMiniPlayerSessionMemory.lastInputText = it.text + }, + modifier = Modifier + .weight(1f) + .focusRequester(inputFocusRequester) + .onFocusChanged { isInputFocused = it.isFocused } + .onPreviewKeyEvent { event -> + val exitKeys = event.key == Key.DirectionRight || + event.key == Key.DirectionDown || + event.key == Key.Tab || + event.key == Key.Back + val loadKeys = event.key == Key.DirectionCenter || event.key == Key.Enter + + when { + (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && exitKeys -> { + if (event.type == KeyEventType.KeyDown) { + exitInputAndFocusLoadButton() + } + true + } + event.type == KeyEventType.KeyDown && loadKeys -> { + loadUrl(YoutubeLoadMode.Embed) + exitInputAndFocusLoadButton() + true + } + else -> false + } + }, + singleLine = true, + label = { Text("Lien ou code YouTube") }, + placeholder = { Text("CTGbG3bygLg") } + ) + Button( + onClick = { loadUrl(YoutubeLoadMode.Embed) }, + modifier = Modifier.focusRequester(loadButtonFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00A9D6)) + ) { + Text("Charger") + } + Button( + onClick = { isMinimized = true }, + modifier = Modifier.focusRequester(minimizeButtonFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF44515A)) + ) { + Text("Minimiser") + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (isMinimized) 112.dp else 240.dp) + .background(Color.Black, RoundedCornerShape(if (isMinimized) 10.dp else 12.dp)), + contentAlignment = Alignment.Center + ) { + if (loadedUrl.isBlank()) { + Text("Aucun lien chargé", color = Color(0xFFB8C7D1)) + } else { + ComposeKey("hermes-youtube-webview") { + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(false) + settings.userAgentString = DESKTOP_USER_AGENT + + runCatching { + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + } + + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + val message = consoleMessage?.message().orEmpty() + if (message.startsWith("HERMES_YT:")) { + status = message.removePrefix("HERMES_YT:") + return true + } + return super.onConsoleMessage(consoleMessage) + } + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + return false + } + + override fun onPageFinished(view: WebView?, url: String?) { + if (!status.startsWith("YouTube erreur") && !isMinimized) { + status = if (loadMode == YoutubeLoadMode.Site) { + "Site YouTube chargé : cliquez Play si nécessaire" + } else { + "Player chargé : OK = Play/Pause" + } + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + if (request?.isForMainFrame == true) { + status = "Erreur WebView : ${error?.description ?: "chargement impossible"}" + } + } + } + setBackgroundColor(android.graphics.Color.BLACK) + }.also { webView = it } + }, + update = { view -> + webView = view + val currentTarget = target + val tagValue = "${loadedUrl}|${loadMode.name}" + if (view.tag != tagValue) { + view.tag = tagValue + when { + currentTarget == null -> Unit + loadMode == YoutubeLoadMode.Site -> { + view.loadUrl( + currentTarget.toWatchOrNormalizedUrl(), + youtubeHeaders() + ) + } + currentTarget.canUseEmbed -> { + view.loadDataWithBaseURL( + HERMES_YOUTUBE_ORIGIN, + buildYoutubeStaticIframeHtml(currentTarget), + "text/html", + "utf-8", + currentTarget.toEmbedUrl() + ) + } + else -> { + view.loadUrl( + currentTarget.toWatchOrNormalizedUrl(), + youtubeHeaders() + ) + } + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + + if (isMinimized) { + Button( + onClick = { restoreFromMinimized() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(3.dp) + .focusRequester(minimizedFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color.Black.copy(alpha = 0.58f)) + ) { + Text("OK agrandir", color = Color.White) + } + } + } + + if (!isMinimized) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { sendCommand(YoutubeCommandType.SeekBackward) }) { Text("-10s") } + Button(onClick = { sendCommand(YoutubeCommandType.TogglePlayPause) }) { Text("Pause / Play") } + Button(onClick = { sendCommand(YoutubeCommandType.SeekForward) }) { Text("+10s") } + Spacer(Modifier.width(8.dp)) + Text( + text = status, + color = Color(0xFFB8C7D1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +private const val DEFAULT_YOUTUBE_WATCH_PREFIX = "https://www.youtube.com/watch?v=" +private val YOUTUBE_VIDEO_ID_PATTERN = Regex("^[A-Za-z0-9_-]{8,20}$") + +private const val HERMES_YOUTUBE_ORIGIN = "https://hermes-youtube.local/" +private const val DESKTOP_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +private fun youtubeHeaders(): Map = mapOf( + "Referer" to HERMES_YOUTUBE_ORIGIN, + "Origin" to HERMES_YOUTUBE_ORIGIN.trimEnd('/') +) + +private fun normalizeYoutubeUrl(raw: String): String { + val value = raw.trim() + if (value.isBlank() || value == DEFAULT_YOUTUBE_WATCH_PREFIX) return "" + + return when { + value.startsWith("http://") || value.startsWith("https://") -> value + value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value" + value.startsWith("watch?v=") -> "https://www.youtube.com/$value" + value.startsWith("v=") -> DEFAULT_YOUTUBE_WATCH_PREFIX + value.removePrefix("v=") + YOUTUBE_VIDEO_ID_PATTERN.matches(value) -> DEFAULT_YOUTUBE_WATCH_PREFIX + value + else -> "https://$value" + } +} + +private fun parseYoutubeTarget(rawUrl: String): YoutubeEmbedTarget? { + val normalized = normalizeYoutubeUrl(rawUrl) + if (normalized.isBlank()) return null + + return runCatching { + val uri = Uri.parse(normalized) + val host = uri.host.orEmpty().lowercase() + val pathSegments = uri.pathSegments + + val videoId = when { + host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull() + uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v") + pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1] + else -> null + }?.takeIf { it.length in 8..20 } + + val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() } + + YoutubeEmbedTarget( + normalizedUrl = normalized, + videoId = videoId, + playlistId = playlistId, + channelOrSiteUrl = normalized + ) + }.getOrNull() +} + +private fun YoutubeEmbedTarget.toEmbedUrl(): String { + val origin = HERMES_YOUTUBE_ORIGIN.trimEnd('/').urlEncode() + val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin" + return when { + videoId != null -> "https://www.youtube.com/embed/$videoId?$common" + playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${playlistId.urlEncode()}&$common" + else -> normalizedUrl + } +} + +private fun YoutubeEmbedTarget.toWatchOrNormalizedUrl(): String { + return when { + videoId != null -> "https://www.youtube.com/watch?v=$videoId" + else -> channelOrSiteUrl ?: normalizedUrl + } +} + +private fun buildYoutubeStaticIframeHtml(target: YoutubeEmbedTarget): String { + val embedUrl = target.toEmbedUrl().escapeHtml() + + return """ + + + + + + + + + + + + + + """.trimIndent() +} + +private fun String.escapeHtml(): String = + replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + +private fun String.urlEncode(): String = Uri.encode(this) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt new file mode 100644 index 0000000..53422b0 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt @@ -0,0 +1,40 @@ +package ma.tkgraph.xtreamtv.ui.navigation + +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto + +/** + * État de navigation de l'application. + * Chaque écran est un état avec ses propres données contextuelles. + */ +sealed class NavState { + /** Écran d'accueil = Live TV avec catégories et chaînes. */ + data class Home(val focusStreamId: Int? = null) : NavState() + + /** Lecteur vidéo. */ + data class Player( + val stream: LiveStreamDto, + val allStreams: List = emptyList(), + val currentIndex: Int = 0, + val replayUrl: String? = null, + val returnState: NavState? = null + ) : NavState() + + /** Guide TV global. */ + data object ProgramGuide : NavState() + + /** Guide Replay pour une chaîne spécifique. */ + data class ReplayGuide(val stream: LiveStreamDto) : NavState() + + /** Catalogue VOD (films). */ + data class Vod(val initialCategoryId: Int? = null) : NavState() + + /** Catalogue séries. */ + data class Series(val initialCategoryId: Int? = null) : NavState() + + /** Détail d'une série (saisons + épisodes). */ + data class SeriesDetail(val series: SeriesDto, val info: SeriesInfoDto) : NavState() +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..a8f6de2 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt @@ -0,0 +1,1073 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.app.Activity +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.HomeViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun HomeScreen( + focusStreamId: Int? = null, + viewModel: HomeViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + hasAutoPlayedPin: Boolean = false, + onAutoPlayPin: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val streamsState by viewModel.streams.collectAsStateWithLifecycle() + val selectedCategoryId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + val pinnedId by viewModel.pinnedChannelId.collectAsStateWithLifecycle() + var showExitDialog by remember { mutableStateOf(false) } + var backPressedOnce by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + val activity = (LocalContext.current as? android.app.Activity) + val cardFocusRequesters = remember { mutableStateMapOf() } + val firstActionFocusRequesters = remember { mutableStateMapOf() } + var hasRequestedInitialCardFocus by remember { mutableStateOf(false) } + var activeActionsStreamId by remember { mutableStateOf(null) } + var blockNextActionCenterUpStreamId by remember { mutableStateOf(null) } + + // ── Recherche : accessible par le bouton de recherche dans le header ── + var searchQuery by remember { mutableStateOf("") } + var isSearchFocused by remember { mutableStateOf(false) } + + LaunchedEffect(selectedCategoryId, searchQuery) { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + } + + // Auto-démarrage sur chaîne épinglée + val streamList = (streamsState as? UiState.Success)?.data + LaunchedEffect(streamList) { + if (!hasAutoPlayedPin && streamList != null && streamList.isNotEmpty()) { + val pinId = pinnedId + if (pinId != null) { + val pinnedStream = streamList.find { it.streamId == pinId } + if (pinnedStream != null) { + onAutoPlayPin() + onNavigate(NavState.Player(pinnedStream, streamList, streamList.indexOf(pinnedStream))) + } + } + } + } + + // Exit dialog + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false; backPressedOnce = false }, + title = { Text("Quitter iMedia TV+ ?", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { Text("Êtes-vous sûr de vouloir quitter ?", color = DesignColors.muted) }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { showExitDialog = false; activity?.finishAffinity() }) { + Text("Oui", color = DesignColors.orange) + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false; backPressedOnce = false }) { + Text("Non", color = DesignColors.muted) + } + } + ) + } + + // Settings dialog + if (showSettings) { + SettingsDialog( + onDismiss = { showSettings = false }, + onLogout = { + showSettings = false + viewModel.logout() + } + ) + } + + // ── Dialog de recherche ── + if (isSearchFocused) { + SearchInputDialog( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onDismiss = { + isSearchFocused = false + if (searchQuery.isBlank()) searchQuery = "" + }, + onSearch = { + isSearchFocused = false + } + ) + } + + // Back handler + BackHandler { + val activeId = activeActionsStreamId + if (activeId != null) { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + try { cardFocusRequesters[activeId]?.requestFocus() } catch (_: Exception) {} + } else if (isSearchFocused) { + isSearchFocused = false + if (searchQuery.isBlank()) searchQuery = "" + } else if (searchQuery.isNotEmpty()) { + searchQuery = "" + } else if (backPressedOnce) { + showExitDialog = true + } else { + backPressedOnce = true + coroutineScope.launch { kotlinx.coroutines.delay(2000); backPressedOnce = false } + } + } + + // ── Arrière-plan avec dégradé ── + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(DesignColors.bg3.copy(alpha = 0.3f), Color.Transparent), + radius = 1200f + ) + ) + .background( + Brush.radialGradient( + colors = listOf(DesignColors.blue.copy(alpha = 0.08f), Color.Transparent), + radius = 1400f, + center = androidx.compose.ui.geometry.Offset(2000f, 100f) + ) + ) + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ── + Box( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Brand : iMedia TV+ avec gradient orange + Text( + "iMedia TV+", + style = MaterialTheme.typography.headlineLarge.copy( + brush = Brush.linearGradient( + colors = listOf(DesignColors.orange, DesignColors.orange2) + ) + ), + fontWeight = FontWeight.Black, + letterSpacing = (-0.055).sp + ) + + // Boutons de navigation + recherche + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + DesignNavChip("Movies", DesignColors.red) { + onNavigate(NavState.Vod()) + } + DesignNavChip("Series", DesignColors.green) { + onNavigate(NavState.Series()) + } + // Bouton paramètres + var settingsFocused by remember { mutableStateOf(false) } + val settingsAlpha by animateFloatAsState( + targetValue = if (settingsFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { showSettings = true }, + modifier = Modifier + .size(32.dp) + .onFocusChanged { settingsFocused = it.isFocused } + .border( + width = if (settingsFocused) 2.dp else 1.dp, + color = if (settingsFocused) Color.White.copy(alpha = settingsAlpha) else DesignColors.stroke, + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = if (settingsFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("⚙", fontSize = 14.sp) + } + } + + // ── Champ de recherche (lecture seule, ouvre une dialog) ── + var searchFieldFocused by remember { mutableStateOf(false) } + Surface( + onClick = { + isSearchFocused = !isSearchFocused + if (isSearchFocused) searchQuery = "" + }, + modifier = Modifier + .widthIn(min = 120.dp, max = 200.dp) + .height(32.dp) + .onFocusChanged { searchFieldFocused = it.isFocused }, + shape = RoundedCornerShape(10.dp), + color = if (searchFieldFocused || isSearchFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF), + border = BorderStroke( + width = if (searchFieldFocused || isSearchFocused) 2.dp else 1.dp, + color = if (searchFieldFocused || isSearchFocused) DesignColors.orange.copy(alpha = 0.7f) else DesignColors.stroke + ) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text("🔍", fontSize = 12.sp) + Text( + if (searchQuery.isEmpty()) "Rechercher…" else searchQuery, + color = if (searchQuery.isEmpty()) DesignColors.muted2 else Color.White, + fontSize = 13.sp, + fontWeight = if (searchQuery.isEmpty()) FontWeight.Normal else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (searchQuery.isNotEmpty()) { + Surface( + onClick = { searchQuery = "" }, + color = Color.Transparent, + shape = RoundedCornerShape(50) + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + Text("✕", color = DesignColors.muted2, fontSize = 10.sp) + } + } + } else if (isSearchFocused) { + Text("⌨", fontSize = 10.sp, color = DesignColors.orange) + } + } + } + } + } + } + + // ── Catégories ── + when (val cats = categoriesState) { + is UiState.Success -> { + val catList = cats.data + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + item { + DesignCategoryChip( + label = "All", + isSelected = selectedCategoryId == null, + onClick = { viewModel.selectCategory(null) } + ) + } + item { + DesignCategoryChip( + label = "★ Favorites", + isSelected = selectedCategoryId == -1, + onClick = { viewModel.selectCategory(-1) } + ) + } + items(catList) { cat -> + val id = cat.categoryId.toIntOrNull() + DesignCategoryChip( + label = cat.categoryName, + isSelected = id == selectedCategoryId && selectedCategoryId != -1, + onClick = { viewModel.selectCategory(id) } + ) + } + } + } + else -> {} + } + + // ── Liste des chaînes ── + when (val strs = streamsState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Chargement des chaînes…", color = DesignColors.muted) + } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(strs.message, color = DesignColors.red) + } + is UiState.Success -> { + val baseFiltered = if (selectedCategoryId == -1) strs.data.filter { it.streamId in favoriteIds } + else strs.data + val filtered = if (searchQuery.isBlank()) baseFiltered + else baseFiltered.filter { it.name.contains(searchQuery, ignoreCase = true) } + + LaunchedEffect(focusStreamId, selectedCategoryId, searchQuery, filtered.size) { + val targetId = focusStreamId + if (targetId != null) { + val index = filtered.indexOfFirst { it.streamId == targetId } + if (index >= 0) { + listState.animateScrollToItem(index) + kotlinx.coroutines.delay(120) + cardFocusRequesters[targetId]?.requestFocus() + } + } else if (!hasRequestedInitialCardFocus && filtered.isNotEmpty()) { + hasRequestedInitialCardFocus = true + kotlinx.coroutines.delay(180) + cardFocusRequesters[filtered.first().streamId]?.requestFocus() + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + state = listState, + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(filtered, key = { it.streamId }) { stream -> + val streamCardFocusRequester = remember(stream.streamId) { + cardFocusRequesters.getOrPut(stream.streamId) { FocusRequester() } + } + val replayFocusRequester = remember(stream.streamId) { + firstActionFocusRequesters.getOrPut(stream.streamId) { FocusRequester() } + } + DesignChannelCard( + modifier = Modifier.focusRequester(streamCardFocusRequester), + name = stream.name, + logoUrl = stream.streamIcon, + isFavorite = stream.streamId in favoriteIds, + isPinned = stream.streamId == pinnedId, + actionsEnabled = activeActionsStreamId == stream.streamId, + firstActionFocusRequester = replayFocusRequester, + consumeFirstActionCenterUp = blockNextActionCenterUpStreamId == stream.streamId, + onFirstActionCenterUpConsumed = { + if (blockNextActionCenterUpStreamId == stream.streamId) { + blockNextActionCenterUpStreamId = null + } + }, + onEnterActions = { + activeActionsStreamId = stream.streamId + blockNextActionCenterUpStreamId = stream.streamId + }, + onExitActions = { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + try { streamCardFocusRequester.requestFocus() } catch (_: Exception) {} + }, + onPlay = { onNavigate(NavState.Player(stream, strs.data, strs.data.indexOf(stream), returnState = NavState.Home(stream.streamId))) }, + onToggleFav = { viewModel.toggleFavorite(stream.streamId) }, + onTogglePin = { viewModel.togglePin(stream.streamId) }, + onReplay = { onNavigate(NavState.ReplayGuide(stream)) } + ) + } + } + } + else -> {} + } + } + } +} + +// ─── NAV CHIP ─── +@Composable +private fun DesignNavChip(label: String, color: Color, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val borderAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(150) + ) + + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) color.copy(alpha = borderAlpha) else DesignColors.stroke, + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = if (isFocused) color.copy(alpha = 0.25f) else Color(0x0DFFFFFF) + ) { + Row( + modifier = Modifier.height(32.dp).padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(label, color = if (isFocused) Color.White else color, fontWeight = FontWeight.Black, fontSize = 14.sp) + } + } +} + +// ─── CATEGORY CHIP ─── +@Composable +private fun DesignCategoryChip(label: String, isSelected: Boolean, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + Surface( + onClick = onClick, + modifier = Modifier.onFocusChanged { isFocused = it.isFocused }, + shape = RoundedCornerShape(999.dp), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .height(28.dp) + .then( + if (isSelected) Modifier.background( + Brush.linearGradient(listOf(DesignColors.orange, DesignColors.orange2)), + RoundedCornerShape(999.dp) + ) else Modifier.background( + if (isFocused) Color(0x22FFFFFF) else Color(0x0DFFFFFF), + RoundedCornerShape(999.dp) + ) + ) + .border( + when { + isSelected -> BorderStroke(1.dp, Color(0xB3FFCA46)) + isFocused -> BorderStroke(1.dp, Color.White.copy(alpha = 0.6f)) + else -> BorderStroke(1.dp, DesignColors.stroke) + }, + RoundedCornerShape(999.dp) + ) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + label, + color = if (isSelected) Color.White else DesignColors.text, + fontWeight = if (isSelected) FontWeight.Black else FontWeight.ExtraBold, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +// ─── CHANNEL CARD ─── +@Composable +private fun DesignChannelCard( + modifier: Modifier = Modifier, + name: String, + logoUrl: String?, + isFavorite: Boolean, + isPinned: Boolean, + actionsEnabled: Boolean, + firstActionFocusRequester: FocusRequester, + consumeFirstActionCenterUp: Boolean, + onFirstActionCenterUpConsumed: () -> Unit, + onEnterActions: () -> Unit, + onExitActions: () -> Unit, + onPlay: () -> Unit, + onToggleFav: () -> Unit, + onTogglePin: () -> Unit, + onReplay: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + var centerIsDown by remember { mutableStateOf(false) } + var centerLongPressTriggered by remember { mutableStateOf(false) } + + val cardBg = Brush.linearGradient( + colors = listOf( + Color(0x7A6B2673), + Color(0xD6141C52), + Color(0xC7081744) + ), + start = androidx.compose.ui.geometry.Offset.Zero, + end = androidx.compose.ui.geometry.Offset(5000f, 5000f) + ) + + fun isCenterKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + val keyCode = event.nativeKeyEvent.keyCode + return keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == android.view.KeyEvent.KEYCODE_ENTER || + event.key == Key.Enter + } + + fun shouldBlockActionCenterEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + return consumeFirstActionCenterUp && + isCenterKey(event) && + (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) + } + + Surface( + modifier = modifier + .fillMaxWidth() + .focusable() + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .onPreviewKeyEvent { event -> + if (actionsEnabled && event.key == Key.DirectionRight) { + if (event.type == KeyEventType.KeyUp) { + try { firstActionFocusRequester.requestFocus() } catch (_: Exception) {} + } + return@onPreviewKeyEvent true + } + + if (!isCenterKey(event)) return@onPreviewKeyEvent false + + when (event.type) { + KeyEventType.KeyDown -> { + if (!centerIsDown) { + centerIsDown = true + centerLongPressTriggered = false + } + + val isLongPress = event.nativeKeyEvent.repeatCount > 0 || event.nativeKeyEvent.isLongPress + if (isLongPress && !centerLongPressTriggered) { + centerLongPressTriggered = true + onEnterActions() + } + true + } + KeyEventType.KeyUp -> { + val wasLongPress = centerLongPressTriggered + centerIsDown = false + centerLongPressTriggered = false + if (wasLongPress) onFirstActionCenterUpConsumed() else onPlay() + true + } + else -> false + } + } + .border( + width = if (isPinned) 3.dp else 1.dp, + color = if (isPinned) DesignColors.orange.copy(alpha = 0.85f) + else if (isFavorite) Color(0xFFFFD700).copy(alpha = 0.5f) + else Color(0x297896FF), + shape = RoundedCornerShape(14.dp) + ) + .background(cardBg, RoundedCornerShape(14.dp)) + .tvFocusHighlight( + focusedBorderColor = if (actionsEnabled) Color.White else DesignColors.orange, + unfocusedBorderColor = Color.Transparent, + borderWidth = 3.dp, + cornerRadius = 14.dp + ), + shape = RoundedCornerShape(14.dp), + color = Color.Transparent + ) { + Row( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Logo + Surface( + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(9.dp), + color = Color.White, + shadowElevation = 4.dp + ) { + Box(contentAlignment = Alignment.Center) { + if (logoUrl != null) { + coil3.compose.AsyncImage( + model = logoUrl, + contentDescription = name, + modifier = Modifier.size(36.dp).clip(RoundedCornerShape(9.dp)) + ) + } else { + Text( + name.take(2).uppercase(), + color = DesignColors.bg1, + fontWeight = FontWeight.Black, + fontSize = 14.sp + ) + } + } + } + + Spacer(Modifier.width(10.dp)) + + // Infos + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = DesignColors.liveRed + ) { + Row( + modifier = Modifier.height(18.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(4.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFFFFD66B)) + ) + Text("LIVE", color = Color.White, fontWeight = FontWeight.Black, fontSize = 10.sp) + } + } + + Text( + name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Black, + color = DesignColors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color(0x3E0D5BCE) + ) { + Box(Modifier.height(16.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.Center) { + Text("IPTV", color = DesignColors.blue.copy(alpha = 0.8f), fontWeight = FontWeight.Black, fontSize = 10.sp) + } + } + Box(Modifier.width(1.dp).height(16.dp).background(DesignColors.stroke)) + Text( + if (actionsEnabled) "Actions : Replay / Pin / Favori" else name, + color = if (actionsEnabled) Color.White.copy(alpha = 0.85f) else DesignColors.muted, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(10.dp)) + + // Actions : désactivées tant que le mode actions n'est pas ouvert par appui long OK. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (actionsEnabled) { + Surface( + onClick = onReplay, + modifier = Modifier + .focusRequester(firstActionFocusRequester) + .onPreviewKeyEvent { event -> + when { + shouldBlockActionCenterEvent(event) -> { + if (event.type == KeyEventType.KeyUp) onFirstActionCenterUpConsumed() + true + } + event.type == KeyEventType.KeyUp && event.key == Key.DirectionLeft -> { + onExitActions() + true + } + else -> false + } + } + .tvFocusHighlight( + focusedBorderColor = Color.White, + borderWidth = 2.dp, + cornerRadius = 8.dp + ), + shape = RoundedCornerShape(8.dp), + color = DesignColors.blue, + border = BorderStroke(1.dp, Color(0xAD60C2FF)) + ) { + Row( + modifier = Modifier.height(28.dp).padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("Replay", color = Color.White, fontWeight = FontWeight.Black, fontSize = 13.sp) + } + } + } else { + Surface( + shape = RoundedCornerShape(8.dp), + color = DesignColors.blue.copy(alpha = 0.45f), + border = BorderStroke(1.dp, Color(0x5560C2FF)) + ) { + Row( + modifier = Modifier.height(28.dp).padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("Replay", color = Color.White.copy(alpha = 0.65f), fontWeight = FontWeight.Black, fontSize = 13.sp) + } + } + } + + Box(Modifier.width(1.dp).height(24.dp).background(Color(0x33FFFFFF))) + + if (actionsEnabled) { + Surface( + onClick = onTogglePin, + modifier = Modifier + .onPreviewKeyEvent { event -> + when { + shouldBlockActionCenterEvent(event) -> { + if (event.type == KeyEventType.KeyUp) onFirstActionCenterUpConsumed() + true + } + event.type == KeyEventType.KeyUp && event.key == Key.DirectionLeft -> { + onExitActions() + true + } + else -> false + } + } + .tvFocusHighlight( + focusedBorderColor = DesignColors.orange, + borderWidth = 2.dp, + cornerRadius = 8.dp + ), + shape = RoundedCornerShape(8.dp), + color = Color.Transparent + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + Text(if (isPinned) "📌" else "📍", fontSize = 14.sp) + } + } + } else { + Surface(shape = RoundedCornerShape(8.dp), color = Color.Transparent) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + Text(if (isPinned) "📌" else "📍", fontSize = 14.sp, color = Color.White.copy(alpha = 0.55f)) + } + } + } + + if (actionsEnabled) { + Surface( + onClick = onToggleFav, + modifier = Modifier + .onPreviewKeyEvent { event -> + when { + shouldBlockActionCenterEvent(event) -> { + if (event.type == KeyEventType.KeyUp) onFirstActionCenterUpConsumed() + true + } + event.type == KeyEventType.KeyUp && event.key == Key.DirectionLeft -> { + onExitActions() + true + } + else -> false + } + } + .tvFocusHighlight( + focusedBorderColor = Color(0xFFFFD700), + borderWidth = 2.dp, + cornerRadius = 8.dp + ), + shape = RoundedCornerShape(8.dp), + color = Color.Transparent + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + Text( + if (isFavorite) "★" else "☆", + fontSize = 16.sp, + color = if (isFavorite) Color(0xFFFFD700) else DesignColors.muted + ) + } + } + } else { + Surface(shape = RoundedCornerShape(8.dp), color = Color.Transparent) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + Text( + if (isFavorite) "★" else "☆", + fontSize = 16.sp, + color = if (isFavorite) Color(0xFFFFD700).copy(alpha = 0.65f) else DesignColors.muted.copy(alpha = 0.55f) + ) + } + } + } + } + } + } +} + +// ─── SETTINGS DIALOG ─── +@Composable +private fun SettingsDialog(onDismiss: () -> Unit, onLogout: () -> Unit) { + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Paramètres", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { + Column { + if (config != null) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("Source : ", color = DesignColors.muted, fontSize = 14.sp) + Text( + if (config.sourceType == SourceType.XTREAM) "Xtream" else "M3U", + color = DesignColors.orange, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + if (config.sourceType == SourceType.XTREAM) { + InfoRow("Serveur", config.serverUrl) + InfoRow("Utilisateur", config.username) + } else { + InfoRow("URL M3U", config.m3uUrl) + } + } + Spacer(Modifier.height(12.dp)) + Text( + "Appuyez sur Déconnecter pour changer de source.", + color = DesignColors.muted2, + fontSize = 12.sp + ) + } + }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { onLogout(); onDismiss() }) { + Text("Déconnecter", color = DesignColors.red) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Fermer", color = DesignColors.muted) } + } + ) +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("$label : ", color = DesignColors.muted, fontSize = 13.sp) + Text(value, color = DesignColors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } +} + +// ─── SEARCH INPUT DIALOG ─── +@Composable +private fun SearchInputDialog( + query: String, + onQueryChange: (String) -> Unit, + onDismiss: () -> Unit, + onSearch: () -> Unit +) { + var localQuery by remember(query) { mutableStateOf(query) } + val textFocus = remember { FocusRequester() } + val okFocus = remember { FocusRequester() } + var textHasFocus by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + try { textFocus.requestFocus() } catch (_: Exception) {} + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xCC000000)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { } + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .widthIn(max = 500.dp) + .background(DesignColors.bg2, RoundedCornerShape(16.dp)) + .border(1.dp, DesignColors.stroke, RoundedCornerShape(16.dp)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Titre + Text( + "🔍 Rechercher une chaîne", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ) + Spacer(Modifier.height(16.dp)) + + // Champ de saisie + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0x22FFFFFF), + border = BorderStroke( + 1.dp, + if (localQuery.isNotEmpty()) DesignColors.orange.copy(alpha = 0.6f) else DesignColors.stroke + ) + ) { + BasicTextField( + value = localQuery, + onValueChange = { + localQuery = it + onQueryChange(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .focusRequester(textFocus) + .onFocusChanged { textHasFocus = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key == Key.DirectionDown) { + textHasFocus = false + try { okFocus.requestFocus() } catch (_: Exception) {} + true + } else false + }, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium + ), + decorationBox = { innerTextField -> + Box { + if (localQuery.isEmpty()) { + Text( + "Tapez le nom de la chaîne…", + color = DesignColors.muted2, fontSize = 16.sp + ) + } + innerTextField() + } + } + ) + } + + Spacer(Modifier.height(20.dp)) + + // Indication du nombre de résultats + if (localQuery.isNotEmpty()) { + Text( + "Filtrage en cours…", + color = DesignColors.muted2, fontSize = 13.sp + ) + Spacer(Modifier.height(12.dp)) + } + + // Boutons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Annuler + var cancelFocused by remember { mutableStateOf(false) } + val cancelAlpha by animateFloatAsState( + targetValue = if (cancelFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { + onQueryChange(localQuery) + onDismiss() + }, + modifier = Modifier + .onFocusChanged { cancelFocused = it.isFocused } + .border( + width = if (cancelFocused) 2.dp else 0.dp, + color = Color.White.copy(alpha = cancelAlpha), + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = Color.Transparent + ) { + Box(Modifier.height(36.dp).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) { + Text( + "Annuler", + color = if (cancelFocused) Color.White else DesignColors.muted, + fontWeight = if (cancelFocused) FontWeight.Bold else FontWeight.Medium, + fontSize = 14.sp + ) + } + } + + Spacer(Modifier.width(8.dp)) + + // OK + var okFocused by remember { mutableStateOf(false) } + val okAlpha by animateFloatAsState( + targetValue = if (okFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { + onQueryChange(localQuery) + onSearch() + }, + modifier = Modifier + .focusRequester(okFocus) + .onFocusChanged { okFocused = it.isFocused } + .border( + width = if (okFocused) 2.dp else 0.dp, + color = DesignColors.orange.copy(alpha = okAlpha), + shape = RoundedCornerShape(8.dp) + ) + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key == Key.DirectionUp) { + okFocused = false + textHasFocus = true + try { textFocus.requestFocus() } catch (_: Exception) {} + true + } else false + }, + shape = RoundedCornerShape(8.dp), + color = DesignColors.orange + ) { + Box(Modifier.height(36.dp).padding(horizontal = 24.dp), contentAlignment = Alignment.Center) { + Text( + "OK", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + } + } + + // Indice navigation + Spacer(Modifier.height(8.dp)) + Text( + "⬇ pour OK • BACK pour fermer", + color = DesignColors.muted2, + fontSize = 11.sp + ) + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..d602cd9 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt @@ -0,0 +1,397 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextInputAction +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel + +/** + * Écran de connexion / configuration. + * Permet de choisir entre serveur Xtream et fichier M3U. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen(viewModel: LoginViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val serverUrl = viewModel.serverUrl + val username = viewModel.username + val password = viewModel.password + val m3uUrl = viewModel.m3uUrl + val sourceType = viewModel.sourceType + var showModeMenu by remember { mutableStateOf(false) } + val serverFocusRequester = remember { FocusRequester() } + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + val m3uFocusRequester = remember { FocusRequester() } + val submitFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(sourceType) { + runCatching { + if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus() + else m3uFocusRequester.requestFocus() + } + } + + // Vérifier si des credentials sont déjà stockés + val savedConfig = remember { + XtreamApplication.instance.appContainer.secureStorage.getConfig() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = DesignColors.bg1 + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + .verticalScroll(rememberScrollState()) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(Modifier.height(32.dp)) + + // Titre avec gradient orange + Text( + text = "iMedia TV+", + style = MaterialTheme.typography.headlineLarge.copy( + brush = Brush.linearGradient( + colors = listOf(DesignColors.orange, DesignColors.orange2) + ) + ), + fontWeight = FontWeight.Black, + letterSpacing = (-0.055).sp + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Configuration", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF888888) + ) + + Spacer(Modifier.height(24.dp)) + + // ─── Sélecteur de mode ─── + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ModeButton( + label = "Xtream", + isSelected = sourceType == SourceType.XTREAM, + onClick = { viewModel.switchSourceType(SourceType.XTREAM) } + ) + ModeButton( + label = "M3U", + isSelected = sourceType == SourceType.M3U, + onClick = { viewModel.switchSourceType(SourceType.M3U) } + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // ─── Champs selon le mode ─── + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (sourceType == SourceType.XTREAM) { + // ── Mode Xtream ── + Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = serverUrl, + onValueChange = { viewModel.updateServerUrl(it) }, + label = { Text("URL du serveur") }, + placeholder = { Text("http://serveur:8080") }, + singleLine = true, + enabled = uiState !is UiState.Loading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { usernameFocusRequester.requestFocus() } + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(serverFocusRequester) + .onFocusChanged { if (it.isFocused) keyboardController?.show() } + .moveFocusOnDpad( + down = { usernameFocusRequester.requestFocus() } + ), + colors = fieldColors() + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = username, + onValueChange = { viewModel.updateUsername(it) }, + label = { Text("Identifiant") }, + singleLine = true, + enabled = uiState !is UiState.Loading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { passwordFocusRequester.requestFocus() } + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(usernameFocusRequester) + .onFocusChanged { if (it.isFocused) keyboardController?.show() } + .moveFocusOnDpad( + up = { serverFocusRequester.requestFocus() }, + down = { passwordFocusRequester.requestFocus() } + ), + colors = fieldColors() + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = password, + onValueChange = { viewModel.updatePassword(it) }, + label = { Text("Mot de passe") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = uiState !is UiState.Loading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide(); viewModel.login() } + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(passwordFocusRequester) + .onFocusChanged { if (it.isFocused) keyboardController?.show() } + .moveFocusOnDpad( + up = { usernameFocusRequester.requestFocus() }, + down = { submitFocusRequester.requestFocus() } + ), + colors = fieldColors() + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.login() }, + enabled = uiState !is UiState.Loading + && serverUrl.isNotBlank() + && username.isNotBlank() + && password.isNotBlank(), + modifier = Modifier + .width(300.dp) + .height(48.dp) + .align(Alignment.CenterHorizontally) + .focusRequester(submitFocusRequester) + .moveFocusOnDpad( + up = { passwordFocusRequester.requestFocus() } + ), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914)) + ) { + if (uiState is UiState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } + } else { + // ── Mode M3U ── + Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = m3uUrl, + onValueChange = { viewModel.updateM3uUrl(it) }, + label = { Text("URL du fichier M3U") }, + placeholder = { Text("https://exemple.com/playlist.m3u") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide(); viewModel.saveM3uConfig() } + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(m3uFocusRequester) + .onFocusChanged { if (it.isFocused) keyboardController?.show() } + .moveFocusOnDpad( + down = { submitFocusRequester.requestFocus() } + ), + colors = fieldColors() + ) + Spacer(Modifier.height(4.dp)) + Text( + "Entrez l'URL d'un fichier M3U distant", + color = Color(0xFF666666), + fontSize = 11.sp + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.saveM3uConfig() }, + enabled = m3uUrl.isNotBlank(), + modifier = Modifier + .width(300.dp) + .height(48.dp) + .align(Alignment.CenterHorizontally) + .focusRequester(submitFocusRequester) + .moveFocusOnDpad( + up = { m3uFocusRequester.requestFocus() } + ), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { + Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } + } + } + + // Message d'erreur + if (uiState is UiState.Error) { + Spacer(Modifier.height(16.dp)) + Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) { + Text( + text = (uiState as UiState.Error).message, + modifier = Modifier.padding(16.dp), + color = Color(0xFFFF5252) + ) + } + } + + // Si déjà connecté, afficher les infos + if (savedConfig != null) { + Spacer(Modifier.height(16.dp)) + Surface( + shape = RoundedCornerShape(10.dp), + color = Color(0xFF1A3A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + TextButton(onClick = { viewModel.logout() }) { + Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(8.dp), + color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333), + modifier = Modifier.weight(1f) + ) { + Text( + label, + modifier = Modifier.padding(vertical = 10.dp), + color = if (isSelected) Color.White else Color(0xFFAAAAAA), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } +} + +private fun Modifier.moveFocusOnDpad( + up: (() -> Unit)? = null, + down: (() -> Unit)? = null +): Modifier = onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + + when (event.key) { + Key.DirectionUp -> { + up?.invoke() + up != null + } + Key.DirectionDown -> { + down?.invoke() + down != null + } + else -> false + } +} + +@Composable +private fun fieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFFE50914), + unfocusedBorderColor = Color(0xFF333333), + focusedLabelColor = Color(0xFFE50914), + unfocusedLabelColor = Color(0xFF888888), + cursorColor = Color.White, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White +) diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..2e86677 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt @@ -0,0 +1,285 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.view.ViewGroup +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel +import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto + +@Composable +fun PlayerScreen( + initialStream: LiveStreamDto, + allStreams: List, + initialIndex: Int, + replayUrl: String? = null, + viewModel: PlayerViewModel = viewModel(), + onBack: () -> Unit = {} +) { + val context = LocalContext.current + val state by viewModel.state.collectAsStateWithLifecycle() + var player by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + var youtubeVisible by remember { mutableStateOf(false) } + + LaunchedEffect(initialStream) { + viewModel.initialize(initialStream, allStreams, initialIndex) + } + + // Use replay URL if provided, otherwise build live URL + val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) { + replayUrl ?: viewModel.buildUrl(state.stream) + } + + DisposableEffect(Unit) { + onDispose { player?.release() } + } + + // Auto-hide overlay after 3 seconds + LaunchedEffect(state.showOverlay) { + if (state.showOverlay) { + delay(3000) + viewModel.hideOverlay() + } + } + + // Update player when channel changes, with resolution listener + LaunchedEffect(streamUrl) { + if (streamUrl.isNotEmpty()) { + val oldPlayer = player + if (oldPlayer != null) { + oldPlayer.stop() + oldPlayer.release() + } + + val newPlayer = ExoPlayer.Builder(context).build().apply { + volume = if (youtubeVisible) 0f else 1f + setMediaItem(MediaItem.fromUri(streamUrl)) + addListener(object : Player.Listener { + override fun onVideoSizeChanged(videoSize: VideoSize) { + if (videoSize.width > 0 && videoSize.height > 0) { + val res = "${videoSize.width}x${videoSize.height}" + viewModel.updateResolution(res) + } + } + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + viewModel.resetFreezeCounter() + } + } + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + viewModel.resetFreezeCounter() + } + } + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + // Si le replay échoue, essayer le live en fallback + if (replayUrl != null) { + player?.stop() + player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream))) + player?.prepare() + player?.play() + } + } + }) + prepare() + play() + } + player = newPlayer + + // Freeze detection : attend 15s de grâce au début, + // puis ne redémarre qu'après 10s de buffering continu + val startTime = System.currentTimeMillis() + while (isActive && viewModel.canRestart()) { + delay(5000) + val elapsed = System.currentTimeMillis() - startTime + if (elapsed < 15_000) continue // grâce de 15s au démarrage + val p = player + if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) { + delay(5000) + if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) { + viewModel.requestRestart() + } + } + } + } + } + + LaunchedEffect(player, youtubeVisible) { + player?.volume = if (youtubeVisible) 0f else 1f + } + + // Request focus for remote-control key events + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + + val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + if (youtubeVisible) { + // IMPORTANT : quand le mini lecteur YouTube est ouvert, + // le parent ne consomme plus aucune touche. + // Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte. + // Cela évite les différences de focus entre modèles de Mi Box. + false + } else { + when (event.key) { + Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true } + Key.DirectionLeft -> { viewModel.zappingLeft(); true } + Key.DirectionRight -> { viewModel.zappingRight(); true } + Key.DirectionCenter, Key.Enter -> { viewModel.toggleOverlay(); true } + Key.Back -> { + if (state.showOverlay) { + player?.stop() + player?.release() + player = null + onBack() + } else { + viewModel.toggleOverlay() + } + true + } + else -> false + } + } + } else false + } + ) { + // Video player area + if (!isLoadingStream) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + useController = false + keepScreenOn = true + } + }, + update = { view -> view.player = player }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text("Chargement du flux…", color = Color.Gray) + } + } + } + + // Overlay : logo + nom + résolution + programme en cours/suivant + AnimatedVisibility( + visible = state.showOverlay, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.TopStart).fillMaxWidth() + ) { + Surface( + color = Color.Black.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth().padding(16.dp) + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.Top) { + // Logo + if (state.stream.streamIcon != null) { + coil3.compose.AsyncImage( + model = state.stream.streamIcon, + contentDescription = state.stream.name, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + // Nom de la chaîne + Text( + state.stream.name, + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Résolution + if (state.resolution.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium) + } + + // Programme en cours + if (state.currentProgram.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall) + Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + + // Programme suivant + if (state.nextProgram.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall) + Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + + if (youtubeVisible) { + YoutubeMiniPlayer( + onClose = { youtubeVisible = false }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp) + ) + } + + // Bottom controls hint + if (state.showOverlay && allStreams.size > 1) { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text("▲ YouTube discret", color = Color.LightGray) + Text("◀ Chaîne précédente", color = Color.LightGray) + Text("Retour : menu", color = Color.LightGray) + Text("Chaîne suivante ▶", color = Color.LightGray) + } + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt new file mode 100644 index 0000000..7755e75 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt @@ -0,0 +1,112 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun ProgramGuideScreen( + viewModel: GuideViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val streamsState by viewModel.streams.collectAsStateWithLifecycle() + val epgState by viewModel.epg.collectAsStateWithLifecycle() + val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle() + + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back") } + Spacer(Modifier.width(16.dp)) + Text("TV Guide", style = MaterialTheme.typography.headlineMedium) + } + + when (val strs = streamsState) { + is UiState.Success -> { + val streamList = strs.data + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(streamList) { stream -> + Surface( + onClick = { viewModel.selectStream(stream.streamId) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cornerRadius = 10.dp + ), + shape = RoundedCornerShape(10.dp), + color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(stream.name, style = MaterialTheme.typography.bodyLarge) + if (stream.streamId == selectedStreamId) { + when (val epg = epgState) { + is UiState.Success -> { + epg.data.take(5).forEach { program -> + Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall) + else -> {} + } + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SuggestionChip( + onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Live") } + ) + SuggestionChip( + onClick = { onNavigate(NavState.ReplayGuide(stream)) }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Replay") } + ) + } + } + } + } + } + } + } + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + else -> {} + } + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt new file mode 100644 index 0000000..95f43ad --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt @@ -0,0 +1,96 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel + +@Composable +fun ReplayGuideScreen( + stream: LiveStreamDto, + viewModel: ReplayViewModel = viewModel(), + onBack: () -> Unit = {}, + onPlayReplay: (String) -> Unit = {} +) { + val epgState by viewModel.epg.collectAsStateWithLifecycle() + + LaunchedEffect(stream) { viewModel.loadReplay(stream) } + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back") } + Spacer(Modifier.width(16.dp)) + Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium) + } + + when (val epg = epgState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) } + is UiState.Success -> { + val grouped = epg.data.groupBy { it.start?.take(10) ?: "" } + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + grouped.forEach { (day, programs) -> + item { + Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp)) + } + items(programs) { program -> + Surface( + onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cornerRadius = 10.dp + ), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f)) { + Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SuggestionChip( + onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Revoir") } + ) + } + } + } + } + } + } + else -> {} + } + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt new file mode 100644 index 0000000..7f86ce0 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt @@ -0,0 +1,210 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.EpisodeDto +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun SeriesScreen( + viewModel: SeriesViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val seriesState by viewModel.series.collectAsStateWithLifecycle() + val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle() + val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + var selectedSeries by remember { mutableStateOf(null) } + + BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { + if (selectedSeries != null) selectedSeries = null + else onBack() + }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = Color(0xFF4CAF50), + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) } + Spacer(Modifier.width(8.dp)) + Text(if (selectedSeries != null) selectedSeries!!.name else "Series", + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50)) + } + + // Categories (hidden in detail view) + if (selectedSeries == null) { + val catData = categoriesState + if (catData is UiState.Success) { + val catList = catData.data + LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } } + item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } } + catList.forEach { cat -> + item(key = cat.categoryId) { + Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) } + } + } + } + } + } + + // Detail view + val detailSeries = selectedSeries + if (detailSeries != null) { + val info = seriesInfoState + when { + info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) } + info is UiState.Success -> { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + info.data.seasons.forEach { season -> + item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) } + val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList() + items(episodes, key = { it.id }) { ep -> + Surface(onClick = { + val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials() + if (config != null) { + val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).seriesUrl(ep.id, ep.containerExtension) + onNavigate(NavState.Player( + stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto( + name = "${detailSeries.name} - Ep ${ep.episodeNum}", + streamId = ep.id, + containerExtension = ep.containerExtension + ), + allStreams = emptyList(), + currentIndex = 0, + replayUrl = url, + returnState = NavState.Series() + )) + } + }, modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .tvFocusHighlight( + focusedBorderColor = Color(0xFF4CAF50), + cornerRadius = 10.dp + ), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Ep ${ep.episodeNum}: ${ep.title ?: ep.name ?: ep.episodeName ?: ""}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White, modifier = Modifier.weight(1f)) + Text("\u25B6", color = Color(0xFF4CAF50)) + } + } + } + } + } + } + else -> { Box(Modifier.weight(1f)) } // Idle state + } + return@Surface + } + + // Grid + val s = seriesState + when { + s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) } + s is UiState.Success -> { + val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data + LazyVerticalGrid(columns = GridCells.Adaptive(160.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(seriesList, key = { it.seriesId }) { series -> + Surface(onClick = { selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) { + var favFocused by remember { mutableStateOf(false) } + Box { + Column { + Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) { + if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))) + else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold) + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White) + } + } + IconButton( + onClick = { viewModel.toggleFavorite(series.seriesId) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .onFocusChanged { favFocused = it.isFocused } + .border( + width = if (favFocused) 2.dp else 0.dp, + color = if (favFocused) Color(0xFFFFD700) else Color.Transparent, + shape = RoundedCornerShape(50) + ) + ) { + Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555)) + } + } + } + } + } + } + else -> { Box(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val bgColor = when { + isSelected -> accentColor + isFocused -> Color(0xFF1A3A1A) + else -> Color(0xFF252525) + } + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 3.dp, + color = when { + isFocused -> accentColor + else -> Color.Transparent + }, + shape = RoundedCornerShape(20.dp) + ), + shape = RoundedCornerShape(20.dp), + color = bgColor + ) { + Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp) + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt new file mode 100644 index 0000000..6c61394 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt @@ -0,0 +1,205 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.foundation.border +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun VodScreen( + viewModel: VodViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val moviesState by viewModel.movies.collectAsStateWithLifecycle() + val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = Color(0xFFE50914), + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) } + Spacer(Modifier.width(8.dp)) + Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914)) + } + + // Categories with ★ Favorites + val catsState = categoriesState + when (catsState) { + is UiState.Success -> { + val catList = catsState.data + LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } } + item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } } + catList.forEach { cat -> + item(key = cat.categoryId) { + CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) } + } + } + } + } + else -> {} + } + + // Movies grid with posters + when (val movies = moviesState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) } + is UiState.Success -> { + val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data + LazyVerticalGrid( + columns = GridCells.Adaptive(160.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(movieList, key = { it.streamId }) { movie -> + MovieCard( + name = movie.name, + posterUrl = movie.streamIcon, + rating = movie.rating, + isFavorite = movie.streamId in favoriteIds, + onToggleFav = { viewModel.toggleFavorite(movie.streamId) }, + onPlay = { + val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials() + if (config != null) { + val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension) + onNavigate(NavState.Player( + stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto( + name = movie.name, + streamId = movie.streamId, + streamIcon = movie.streamIcon, + containerExtension = movie.containerExtension + ), + allStreams = emptyList(), + currentIndex = 0, + replayUrl = url, + returnState = NavState.Vod() + )) + } + } + ) + } + } + } + else -> {} + } + } + } +} + +@Composable +private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val bgColor = when { + isSelected -> Color(0xFFE50914) + isFocused -> Color(0xFF3A1A1A) + else -> Color(0xFF252525) + } + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 3.dp, + color = when { + isFocused -> Color(0xFFE50914) + else -> Color.Transparent + }, + shape = RoundedCornerShape(20.dp) + ), + shape = RoundedCornerShape(20.dp), + color = bgColor + ) { + Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp) + } +} + +@Composable +private fun MovieCard(name: String, posterUrl: String?, rating: String?, isFavorite: Boolean, onToggleFav: () -> Unit, onPlay: () -> Unit) { + var favFocused by remember { mutableStateOf(false) } + + Surface( + onClick = onPlay, + modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp), + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + tonalElevation = 2.dp + ) { + Box { + Column { + // Poster + Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) { + if (posterUrl != null) { + coil3.compose.AsyncImage( + model = posterUrl, + contentDescription = name, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + ) + } else { + Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold) + } + } + // Info bar + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White) + if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA)) + } + } + } + // Favorite icon in top-right corner (doesn't steal card focus) + IconButton( + onClick = onToggleFav, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .onFocusChanged { favFocused = it.isFocused } + .border( + width = if (favFocused) 2.dp else 0.dp, + color = if (favFocused) Color(0xFFFFD700) else Color.Transparent, + shape = RoundedCornerShape(50) + ) + ) { + Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555)) + } + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt new file mode 100644 index 0000000..eebe5da --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt @@ -0,0 +1,54 @@ +package ma.tkgraph.xtreamtv.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// ─── Couleurs du design iMedia TV+ ─── +object DesignColors { + val bg1 = Color(0xFF020817) + val bg2 = Color(0xFF071a3d) + val bg3 = Color(0xFF2b0f53) + val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74) + val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70) + val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12) + val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42) + val text = Color(0xFFF7F8FF) + val muted = Color(0xFFA9B2D8) + val muted2 = Color(0xFF7F8AB6) + val orange = Color(0xFFFF512F) + val orange2 = Color(0xFFFFB000) + val blue = Color(0xFF1469FF) + val blue2 = Color(0xFF09B7FF) + val red = Color(0xFFFF3D64) + val green = Color(0xFF00D9B2) + val purple = Color(0xFF875CFF) + val liveRed = Color(0xFFFE3152) + val liveRedDark = Color(0xFFBA1733) +} + +private val DesignColorScheme = darkColorScheme( + primary = DesignColors.orange, + onPrimary = Color.White, + primaryContainer = DesignColors.orange.copy(alpha = 0.2f), + secondary = DesignColors.blue, + onSecondary = Color.White, + surface = Color(0xBD1A1C49), + surfaceVariant = Color(0x70121D49), + onSurface = DesignColors.text, + onSurfaceVariant = DesignColors.muted, + background = DesignColors.bg1, + onBackground = DesignColors.text, + error = DesignColors.red, + onError = Color.White, + outline = DesignColors.stroke +) + +@Composable +fun XtreamTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = DesignColorScheme, + content = content + ) +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt new file mode 100644 index 0000000..2c32f9e --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt @@ -0,0 +1,44 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class GuideViewModel : ViewModel() { + + private val liveRepo = XtreamApplication.instance.appContainer.liveRepository + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + + private val _streams = MutableStateFlow>>(UiState.Success(emptyList())) + val streams: StateFlow>> = _streams.asStateFlow() + + private val _selectedStreamId = MutableStateFlow(null) + val selectedStreamId: StateFlow = _selectedStreamId.asStateFlow() + + private val _epg = MutableStateFlow>>(UiState.Idle) + val epg: StateFlow>> = _epg.asStateFlow() + + init { loadStreams() } + + fun loadStreams() { + viewModelScope.launch { + val r = liveRepo.getStreams() + if (r is UiState.Success) _streams.value = r + } + } + + fun selectStream(streamId: Int) { + _selectedStreamId.value = streamId + viewModelScope.launch { + val r = epgRepo.getFullGuide(streamId) + if (r is UiState.Success) _epg.value = r + } + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt new file mode 100644 index 0000000..258f6f4 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt @@ -0,0 +1,97 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class HomeViewModel : ViewModel() { + + private val liveRepo = XtreamApplication.instance.appContainer.liveRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + + private val _categories = MutableStateFlow>>( + UiState.Success(emptyList()) + ) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _streams = MutableStateFlow>>( + UiState.Success(emptyList()) + ) + val streams: StateFlow>> = _streams.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + // État réactif pour les favoris et l'épinglage + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId()) + val pinnedChannelId: StateFlow = _pinnedChannelId.asStateFlow() + + init { + loadCategories() + loadStreams() + } + + fun loadCategories() { + viewModelScope.launch { + val result = liveRepo.getCategories() + if (result is UiState.Success) { + _categories.value = result + } else if (result is UiState.Error) { + _categories.value = UiState.Success(emptyList()) + } + } + } + + fun selectCategory(categoryId: Int?) { + _selectedCategoryId.value = categoryId + if (categoryId == -1) { + // ★ Favorites : charger toutes les chaînes, filtrer dans l'UI + loadStreams(null) + } else { + loadStreams(categoryId) + } + } + + fun loadStreams(categoryId: Int? = null) { + viewModelScope.launch { + val result = liveRepo.getStreams(categoryId) + if (result is UiState.Success) { + _streams.value = result + } else if (result is UiState.Error) { + _streams.value = UiState.Success(emptyList()) + } + } + } + + fun toggleFavorite(streamId: Int) { + favRepo.toggleFavoriteLive(streamId) + _favoriteIds.value = favRepo.getFavoriteLiveIds() + } + + fun togglePin(streamId: Int) { + val current = _pinnedChannelId.value + if (current == streamId) { + favRepo.setPinnedChannelId(null) + _pinnedChannelId.value = null + } else { + favRepo.setPinnedChannelId(streamId) + _pinnedChannelId.value = streamId + } + } + + /** Déconnexion : efface config + cache M3U. */ + fun logout() { + XtreamApplication.instance.appContainer.m3uRepository.invalidateCache() + XtreamApplication.instance.appContainer.authRepository.logout() + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt new file mode 100644 index 0000000..93b54db --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt @@ -0,0 +1,107 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.repository.AuthRepository +import ma.tkgraph.xtreamtv.data.repository.UserInfo + +/** + * ViewModel pour l'écran de connexion / configuration. + * Gère la connexion Xtream et la configuration M3U. + */ +class LoginViewModel : ViewModel() { + + private val authRepository: AuthRepository = + XtreamApplication.instance.appContainer.authRepository + private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository + + private val _uiState = MutableStateFlow>(UiState.Idle) + val uiState: StateFlow> = _uiState.asStateFlow() + + /** Type de source (Compose-reactive). */ + var sourceType by mutableStateOf(SourceType.XTREAM) + var serverUrl: String = "" + private set + var username: String = "" + private set + var password: String = "" + private set + var m3uUrl: String = "" + private set + + init { + // Charger la config existante + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + if (config != null) { + sourceType = config.sourceType + serverUrl = config.serverUrl + username = config.username + password = config.password + m3uUrl = config.m3uUrl + } + } + + fun switchSourceType(type: SourceType) { + sourceType = type + } + + fun updateServerUrl(value: String) { + serverUrl = value + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updateUsername(value: String) { + username = value + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updatePassword(value: String) { + password = value + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updateM3uUrl(value: String) { + m3uUrl = value + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + /** Connexion au serveur Xtream. */ + fun login() { + if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return + viewModelScope.launch { + _uiState.value = UiState.Loading + val result = authRepository.login(serverUrl, username, password) + _uiState.value = result + } + } + + /** Sauvegarde la config M3U et invalide le cache. */ + fun saveM3uConfig() { + if (m3uUrl.isBlank()) return + m3uRepository.invalidateCache() + authRepository.saveM3uConfig(m3uUrl) + _uiState.value = UiState.Success(UserInfo(username = "M3U")) + } + + /** Déconnexion : efface la config et le cache M3U. */ + fun logout() { + m3uRepository.invalidateCache() + authRepository.logout() + serverUrl = "" + username = "" + password = "" + m3uUrl = "" + sourceType = SourceType.XTREAM + _uiState.value = UiState.Idle + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt new file mode 100644 index 0000000..3bd0541 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt @@ -0,0 +1,153 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository +import ma.tkgraph.xtreamtv.util.UrlBuilder +import timber.log.Timber + +data class PlayerState( + val stream: LiveStreamDto = LiveStreamDto(), + val allStreams: List = emptyList(), + val currentIndex: Int = 0, + val showOverlay: Boolean = true, + val resolution: String = "", + val currentProgram: String = "", + val nextProgram: String = "", + val restartTrigger: Int = 0, + val freezeRetries: Int = 0 +) + +class PlayerViewModel : ViewModel() { + + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + + private val _state = MutableStateFlow(PlayerState(showOverlay = true)) + val state: StateFlow = _state.asStateFlow() + + fun initialize(stream: LiveStreamDto, allStreams: List, index: Int) { + _state.value = PlayerState( + stream = stream, + allStreams = allStreams, + currentIndex = index, + showOverlay = true + ) + loadEpg(stream.streamId) + } + + /** + * Construit l'URL de lecture selon la source configurée. + * - Mode Xtream : construit l'URL via [UrlBuilder] + * - Mode M3U : utilise [LiveStreamDto.directUrl] directement + */ + fun buildUrl(stream: LiveStreamDto): String { + // En mode M3U, utiliser l'URL directe du stream + if (stream.directUrl.isNotBlank()) { + return stream.directUrl + } + // Mode Xtream : construction via UrlBuilder + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + ?: return "" + if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) { + return stream.directUrl + } + return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension) + } + + fun updateResolution(resolution: String) { + _state.value = _state.value.copy(resolution = resolution) + } + + private fun loadEpg(streamId: Int) { + viewModelScope.launch { + val result = epgRepo.getShortEpg(streamId) + if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) { + val programs = result.data + if (programs.isNotEmpty()) { + val now = System.currentTimeMillis() / 1000 + val current = programs.firstOrNull { + val start = it.startTimestamp?.toLongOrNull() + val stop = it.stopTimestamp?.toLongOrNull() + start != null && start <= now && (stop == null || stop >= now) + } ?: programs.firstOrNull() + val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() } + val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1] + else null + _state.value = _state.value.copy( + currentProgram = current?.titleText() ?: "", + nextProgram = next?.titleText() ?: "" + ) + } + } + } + } + + fun zappingLeft() { + val current = _state.value + if (current.allStreams.isEmpty()) return + val newIndex = if (current.currentIndex > 0) current.currentIndex - 1 + else current.allStreams.lastIndex + val newStream = current.allStreams[newIndex] + _state.value = PlayerState( + stream = newStream, + allStreams = current.allStreams, + currentIndex = newIndex, + showOverlay = true + ) + loadEpg(newStream.streamId) + } + + fun zappingRight() { + val current = _state.value + if (current.allStreams.isEmpty()) return + val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1 + else 0 + val newStream = current.allStreams[newIndex] + _state.value = PlayerState( + stream = newStream, + allStreams = current.allStreams, + currentIndex = newIndex, + showOverlay = true + ) + loadEpg(newStream.streamId) + } + + fun toggleOverlay() { + _state.value = _state.value.copy(showOverlay = !_state.value.showOverlay) + } + + fun hideOverlay() { + _state.value = _state.value.copy(showOverlay = false) + } + + fun requestRestart() { + val s = _state.value + if (s.freezeRetries >= 3) return + val retries = s.freezeRetries + 1 + Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3") + _state.value = s.copy( + restartTrigger = s.restartTrigger + 1, + freezeRetries = retries + ) + } + + fun resetFreezeCounter() { + val s = _state.value + if (s.freezeRetries > 0) { + _state.value = s.copy(freezeRetries = 0) + } + } + + fun canRestart(): Boolean = _state.value.freezeRetries < 3 + + fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId) + fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt new file mode 100644 index 0000000..90a5431 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class ReplayViewModel : ViewModel() { + + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + + private val _epg = MutableStateFlow>>(UiState.Idle) + val epg: StateFlow>> = _epg.asStateFlow() + + private var currentStream: LiveStreamDto? = null + + fun loadReplay(stream: LiveStreamDto) { + currentStream = stream + viewModelScope.launch { + _epg.value = UiState.Loading + val result = epgRepo.getFullGuide(stream.streamId) + if (result is UiState.Success) { + // Filtrer : garder uniquement les programmes passés (déjà diffusés) + val nowSeconds = System.currentTimeMillis() / 1000 + val pastPrograms = result.data.filter { prog -> + val start = prog.startTimestamp?.toLongOrNull() ?: 0 + start > 0 && start <= nowSeconds + }.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 } + _epg.value = UiState.Success(pastPrograms) + } else { + _epg.value = result + } + } + } + + fun buildReplayUrl(program: EpgProgramDto): String? { + val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials() + ?: return null + val stream = currentStream ?: return null + + val durationMinutes = program.durationMinutes() + + return ma.tkgraph.xtreamtv.util.UrlBuilder(config) + .replayUrl( + streamId = stream.streamId, + start = program.start.orEmpty(), + durationMinutes = durationMinutes, + extension = "ts" + ) + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt new file mode 100644 index 0000000..7f481a6 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt @@ -0,0 +1,74 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class SeriesViewModel : ViewModel() { + + private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + + private val _categories = MutableStateFlow>>(UiState.Success(emptyList())) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _series = MutableStateFlow>>(UiState.Success(emptyList())) + val series: StateFlow>> = _series.asStateFlow() + + private val _seriesInfo = MutableStateFlow>(UiState.Idle) + val seriesInfo: StateFlow> = _seriesInfo.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + init { + loadCategories() + loadSeries() + } + + fun loadCategories() { + viewModelScope.launch { + val r = seriesRepo.getCategories() + if (r is UiState.Success) _categories.value = r + } + } + + fun loadSeries(categoryId: Int? = null) { + viewModelScope.launch { + val r = seriesRepo.getSeriesList(categoryId) + if (r is UiState.Success) _series.value = r + } + } + + fun selectCategory(categoryId: Int?) { + _selectedCategoryId.value = categoryId + if (categoryId == -1) { + loadSeries(null) // Load all, filter in UI + } else { + loadSeries(categoryId) + } + } + + fun loadSeriesInfo(seriesId: Int) { + viewModelScope.launch { + val r = seriesRepo.getSeriesInfo(seriesId) + if (r is UiState.Success) _seriesInfo.value = r + } + } + + fun toggleFavorite(seriesId: Int) { + favRepo.toggleFavoriteSeries(seriesId) + _favoriteIds.value = favRepo.getFavoriteSeriesIds() + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt new file mode 100644 index 0000000..493d145 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt @@ -0,0 +1,63 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto + +class VodViewModel : ViewModel() { + + private val vodRepo = XtreamApplication.instance.appContainer.vodRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + + private val _categories = MutableStateFlow>>(UiState.Success(emptyList())) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _movies = MutableStateFlow>>(UiState.Success(emptyList())) + val movies: StateFlow>> = _movies.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + init { + loadCategories() + loadMovies() + } + + fun loadCategories() { + viewModelScope.launch { + val r = vodRepo.getCategories() + if (r is UiState.Success) _categories.value = r + } + } + + fun loadMovies(categoryId: Int? = null) { + viewModelScope.launch { + val r = vodRepo.getMovies(categoryId) + if (r is UiState.Success) _movies.value = r + } + } + + fun selectCategory(categoryId: Int?) { + _selectedCategoryId.value = categoryId + if (categoryId == -1) { + loadMovies(null) // Load all, filter in UI + } else { + loadMovies(categoryId) + } + } + + fun toggleFavorite(streamId: Int) { + favRepo.toggleFavoriteVod(streamId) + _favoriteIds.value = favRepo.getFavoriteVodIds() + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt new file mode 100644 index 0000000..ae4dc77 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt @@ -0,0 +1,88 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto + +/** + * Trie les catégories dans l'ordre préféré : + * 1. Catégories spéciales (Toutes, Favoris) + * 2. Catégories prioritaires (Bein Sports, etc.) + * 3. Catégories françaises + * 4. Catégories arabes + * 5. Autres catégories (ordre alphabétique) + */ +object CategorySortUtils { + + private val PRIORITY_KEYWORDS = listOf( + "bein", "beIN" + ) + + private val FRENCH_KEYWORDS = listOf( + "france", "français", "francaise", "française", + "fr", "fra", "francais", "info", "infos" + ) + + private val ARABIC_KEYWORDS = listOf( + "arab", "arabic", "arabe", "arabia", + "arabique", "maghreb", "moyen-orient", + "عربي", "عربية", "العربية" + ) + + private val SPECIAL_NAMES = listOf( + "all channels", "toutes les chaînes", + "all movies", "tous les films", + "all series", "toutes les séries", + "favorites", "favoris", "★" + ) + + fun sortLiveCategories(categories: List): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + fun sortVodCategories(categories: List): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + fun sortSeriesCategories(categories: List): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + private fun categoryComparator( + nameSelector: (T) -> String, + isSpecial: (String) -> Boolean + ): Comparator = Comparator { a, b -> + val nameA = nameSelector(a).lowercase().trim() + val nameB = nameSelector(b).lowercase().trim() + + when { + isSpecial(nameA) && !isSpecial(nameB) -> -1 + !isSpecial(nameA) && isSpecial(nameB) -> 1 + isPriority(nameA) && !isPriority(nameB) -> -1 + !isPriority(nameA) && isPriority(nameB) -> 1 + isFrench(nameA) && !isFrench(nameB) -> -1 + !isFrench(nameA) && isFrench(nameB) -> 1 + isArabic(nameA) && !isArabic(nameB) -> -1 + !isArabic(nameA) && isArabic(nameB) -> 1 + else -> nameA.compareTo(nameB) + } + } + + private fun isPriority(name: String): Boolean = + PRIORITY_KEYWORDS.any { name.contains(it) } + + private fun isFrench(name: String): Boolean = + FRENCH_KEYWORDS.any { name.contains(it) } + + private fun isArabic(name: String): Boolean = + ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt new file mode 100644 index 0000000..d5acb59 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt @@ -0,0 +1,14 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Vérifie l'état de la session au démarrage. + */ +class SessionManager(private val storage: SecureStorage) { + + fun hasValidSession(): Boolean { + val config = storage.getConfig() ?: return false + return config.isValid + } +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt new file mode 100644 index 0000000..5be0070 --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt @@ -0,0 +1,53 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.XtreamConfig + +class UrlBuilder(private val config: XtreamConfig) { + + fun liveUrl(streamId: Int, extension: String = "m3u8"): String = + "${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension" + + fun vodUrl(streamId: Int, extension: String = "mp4"): String = + "${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension" + + fun seriesUrl(episodeId: Int, extension: String = "mp4"): String = + "${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension" + + /** + * Construit l'URL timeshift pour le replay. + * Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts + * + * @param streamId ID de la chaîne + * @param start Date de début au format "2026-06-11 20:00" + * @param durationMinutes Durée en minutes (entre 1 et 720) + * @param extension Extension du fichier (ts par défaut) + */ + fun replayUrl( + streamId: Int, + start: String, + durationMinutes: Int = 120, + extension: String = "ts" + ): String { + val safeDuration = durationMinutes.coerceIn(1, 720) + val formattedStart = formatTimeshiftStart(start) + return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension" + } + + /** + * Formate la date pour le timeshift. + * "2026-06-11 20:00" → "2026-06-11:20-00" + */ + private fun formatTimeshiftStart(start: String): String { + val match = Regex("""(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})""").find(start.trim()) + if (match != null) { + val date = match.groupValues[1] + val hour = match.groupValues[2] + val minute = match.groupValues[3] + return "$date:$hour-$minute" + } + return start.trim().take(16).replace(" ", ":").replace(Regex(""":(\d{2})$"""), "-$1") + } + + private val baseUrl: String + get() = config.serverUrl.trimEnd('/') +} diff --git a/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt new file mode 100644 index 0000000..44c77fe --- /dev/null +++ b/app/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt @@ -0,0 +1,21 @@ +package ma.tkgraph.xtreamtv.util + +/** + * Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur. + * L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement. + */ +object UsageSortUtils { + fun sortByUsage( + items: List, + usageCounts: Map, + idSelector: (T) -> String + ): List { + if (items.size < 2 || usageCounts.isEmpty()) return items + return items.withIndex() + .sortedWith( + compareByDescending> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 } + .thenBy { indexed -> indexed.index } + ) + .map { it.value } + } +} diff --git a/app/app/src/main/res/drawable/ic_banner.png b/app/app/src/main/res/drawable/ic_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..49482aeacde411888611a7e9f382c1b66e3b1b3e GIT binary patch literal 12707 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 B;VJ+C literal 0 HcmV?d00001 diff --git a/app/app/src/main/res/drawable/launch_background.xml b/app/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..5e1fcfb --- /dev/null +++ b/app/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..45e2cf6c41add808f1fc817ab56294f75fa8a1a4 GIT binary patch literal 5154 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%0A~A=9{&z`+&%2 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 B;VJ+C literal 0 HcmV?d00001 diff --git a/app/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0bfcc34c693c0b30f2a8052049dda2b1ca16c130 GIT binary patch literal 8093 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 p-JE4({tssT|4H_qveG*;vNdI$|LBdOLg=lX*e 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 literal 0 HcmV?d00001 diff --git a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9db042ee02a4fe522700147fa43a48eed07f56f0 GIT binary patch literal 25193 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 q^7hn=_W!>e;s5MoIJc~Lq3}%H-K=|dIDM^ + iMedia TV+ + + + البث المباشر + الأفلام + المسلسلات + دليل التلفاز + الإعدادات + + + رابط الخادم + اسم المستخدم + كلمة المرور + اتصال + جارٍ الاتصال… + مثال: http://server:8080 + + + تعذر الاتصال بالخادم. تحقق من شبكتك. + الخادم لا يستجيب. حاول مرة أخرى لاحقاً. + بيانات الدخول غير صحيحة أو الخادم غير صالح. + البث غير متاح لهذه القناة. + خطأ في الخادم (%d). اتصل بمزود الخدمة. + لم يتم العثور على برنامج لهذه القناة. + فشل التحميل. + إعادة المحاولة + + + جميع القنوات + ★ المفضلة + البحث عن قنوات… + لم يتم العثور على قنوات + مثبتة + تثبيت هذه القناة + إلغاء التثبيت + إضافة إلى المفضلة + إزالة من المفضلة + + + قيد التشغيل + القناة التالية + القناة السابقة + البرنامج الحالي + التالي + إعادة + مباشر + + + الإعادة — %s + مشاهدة الإعادة + الإعادة غير متاحة + الأيام المتاحة + + + جميع الأفلام + البحث عن أفلام… + لم يتم العثور على أفلام + مشاهدة الفيلم + + + جميع المسلسلات + البحث عن مسلسلات… + لم يتم العثور على مسلسلات + الموسم %d + الحلقة %d + الحلقة %1$d • %2$s + + + هل تريد الخروج من iMedia TV+؟ + هل أنت متأكد من رغبتك في الخروج؟ + نعم + لا + جارٍ التحميل… + لا توجد بيانات متاحة + قطع الاتصال + الإصدار %s + diff --git a/app/app/src/main/res/values-fr/strings.xml b/app/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..228cf8f --- /dev/null +++ b/app/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,78 @@ + + iMedia TV+ + + + Direct TV + Films + Séries + Guide TV + Paramètres + + + URL du serveur + Identifiant + Mot de passe + Connexion + Connexion… + ex: http://serveur:8080 + + + Impossible de se connecter au serveur. Vérifiez votre réseau. + Le serveur ne répond pas. Réessayez plus tard. + Identifiants incorrects ou serveur invalide. + Flux indisponible pour cette chaîne. + Erreur serveur (%d). Contactez votre fournisseur. + Aucun programme trouvé pour cette chaîne. + Échec du chargement. + Réessayer + + + Toutes les chaînes + ★ Favoris + Rechercher des chaînes… + Aucune chaîne trouvée + Épinglée + Épingler cette chaîne + Désépingler + Ajouter aux favoris + Retirer des favoris + + + En cours + Chaîne suivante + Chaîne précédente + Programme en cours + Suivant + Replay + Direct + + + Replay — %s + Revoir + Replay non disponible + Jours disponibles + + + Tous les films + Rechercher des films… + Aucun film trouvé + Regarder le film + + + Toutes les séries + Rechercher des séries… + Aucune série trouvée + Saison %d + Épisode %d + Épisode %1$d • %2$s + + + Quitter iMedia TV+ ? + Êtes-vous sûr de vouloir quitter ? + Oui + Non + Chargement… + Aucune donnée disponible + Déconnexion + Version %s + diff --git a/app/app/src/main/res/values/colors.xml b/app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..430f745 --- /dev/null +++ b/app/app/src/main/res/values/colors.xml @@ -0,0 +1,19 @@ + + + #FFE50914 + #FFB30000 + #FFFFFFFF + #FF4A1C1C + #FF1A73E8 + #FFFFFFFF + #FF1A1A1A + #FF252525 + #FFFFFFFF + #FF0A0A0A + #FFEEEEEE + #FFFFD700 + #FFFF5252 + #FF4CAF50 + #FF00BCD4 + #FF333333 + diff --git a/app/app/src/main/res/values/config.xml b/app/app/src/main/res/values/config.xml new file mode 100644 index 0000000..9fb09ba --- /dev/null +++ b/app/app/src/main/res/values/config.xml @@ -0,0 +1,9 @@ + + + 3000 + 3000 + 15000 + 30000 + 2 + 15 + diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..42fbad2 --- /dev/null +++ b/app/app/src/main/res/values/strings.xml @@ -0,0 +1,78 @@ + + iMedia TV+ + + + Live TV + Movies + Series + TV Guide + Settings + + + Server URL + Username + Password + Connect + Connecting… + e.g. http://server:8080 + + + Cannot reach server. Check your network. + Server is not responding. Try again later. + Invalid credentials or server. + Stream unavailable for this channel. + Server error (%d). Contact your provider. + No program found for this channel. + Failed to load content. + Retry + + + All Channels + ★ Favorites + Search channels… + No channels found + Pinned + Pin this channel + Unpin + Add to favorites + Remove from favorites + + + Now playing + Next channel + Previous channel + Current program + Next + Replay + Live + + + Replay — %s + Watch replay + No replay available + Available days + + + All Movies + Search movies… + No movies found + Watch movie + + + All Series + Search series… + No series found + Season %d + Episode %d + Episode %1$d • %2$s + + + Exit iMedia TV+? + Are you sure you want to exit? + Yes + No + Loading… + No data available + Disconnect + Version %s + diff --git a/app/app/src/main/res/values/themes.xml b/app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d390a40 --- /dev/null +++ b/app/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/app/src/main/res/xml/network_security_config.xml b/app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..aef732c --- /dev/null +++ b/app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt b/app/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt new file mode 100644 index 0000000..2058c04 --- /dev/null +++ b/app/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt @@ -0,0 +1,74 @@ +package ma.tkgraph.xtreamtv.data.repository + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import ma.tkgraph.xtreamtv.data.model.AuthResponse +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.UserInfo +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AuthRepositoryTest { + + private val api: XtreamApi = mockk() + private val storage: SecureStorage = mockk() + private lateinit var repo: AuthRepository + + @BeforeEach + fun setup() { + repo = AuthRepository(api, storage) + } + + @Test + fun `login with valid credentials returns Success`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + userInfo = UserInfo(username = "testuser"), + auth = "1", + status = "Active" + ) + + // When + val result = repo.login("http://server:8080", "testuser", "pass123") + + // Then + assertTrue(result is UiState.Success) + assertEquals("testuser", (result as UiState.Success).data.username) + } + + @Test + fun `login with invalid auth returns Error`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + auth = "0", + status = "Active" + ) + + // When + val result = repo.login("http://server:8080", "baduser", "badpass") + + // Then + assertTrue(result is UiState.Error) + } + + @Test + fun `login with expired account returns Error`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + auth = "1", + status = "Expired" + ) + + // When + val result = repo.login("http://server:8080", "user", "pass") + + // Then + assertTrue(result is UiState.Error) + assertTrue((result as UiState.Error).message.contains("expired", ignoreCase = true)) + } +} diff --git a/app/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt b/app/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt new file mode 100644 index 0000000..799bde6 --- /dev/null +++ b/app/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt @@ -0,0 +1,50 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CategorySortUtilsTest { + + @Test + fun `sortLiveCategories puts FR first, AR second, others last`() { + // Given + val categories = listOf( + LiveCategoryDto(categoryId = "1", categoryName = "Sports"), + LiveCategoryDto(categoryId = "2", categoryName = "France Info"), + LiveCategoryDto(categoryId = "3", categoryName = "العربية"), + LiveCategoryDto(categoryId = "4", categoryName = "French Cinema"), + LiveCategoryDto(categoryId = "5", categoryName = "News") + ) + + // When + val sorted = CategorySortUtils.sortLiveCategories(categories) + + // Then + assertEquals("France Info", sorted[0].categoryName) + assertEquals("French Cinema", sorted[1].categoryName) + assertEquals("العربية", sorted[2].categoryName) + assertEquals("News", sorted[3].categoryName) + assertEquals("Sports", sorted[4].categoryName) + } + + @Test + fun `special categories always come first`() { + // Given + val categories = listOf( + LiveCategoryDto(categoryId = "1", categoryName = "News"), + LiveCategoryDto(categoryId = "2", categoryName = "★ Favorites"), + LiveCategoryDto(categoryId = "3", categoryName = "All Channels"), + LiveCategoryDto(categoryId = "4", categoryName = "France Info") + ) + + // When + val sorted = CategorySortUtils.sortLiveCategories(categories) + + // Then + assertEquals("All Channels", sorted[0].categoryName) + assertEquals("★ Favorites", sorted[1].categoryName) + assertEquals("France Info", sorted[2].categoryName) + assertEquals("News", sorted[3].categoryName) + } +} diff --git a/app/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt b/app/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt new file mode 100644 index 0000000..ee82bf3 --- /dev/null +++ b/app/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt @@ -0,0 +1,50 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UrlBuilderTest { + + private lateinit var builder: UrlBuilder + private val config = XtreamConfig("http://server:8080", "user", "pass") + + @BeforeEach + fun setup() { + builder = UrlBuilder(config) + } + + @Test + fun `liveUrl builds correct URL`() { + val url = builder.liveUrl(42) + assertEquals("http://server:8080/live/user/pass/42.m3u8", url) + } + + @Test + fun `liveUrl with custom extension`() { + val url = builder.liveUrl(42, "ts") + assertEquals("http://server:8080/live/user/pass/42.ts", url) + } + + @Test + fun `vodUrl builds correct URL`() { + val url = builder.vodUrl(123, "mp4") + assertEquals("http://server:8080/movie/user/pass/123.mp4", url) + } + + @Test + fun `seriesUrl builds correct URL`() { + val url = builder.seriesUrl(456, "mkv") + assertEquals("http://server:8080/series/user/pass/456.mkv", url) + } + + @Test + fun `replayUrl builds correct URL`() { + val url = builder.replayUrl(3600, "2026-06-10:12-00", 42) + assertEquals( + "http://server:8080/timeshift/user/pass/3600/2026-06-10:12-00/42.ts", + url + ) + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6567ee7 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,123 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") version "2.1.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" +} + +android { + namespace = "ma.tkgraph.xtreamtv" + compileSdk = 36 + + defaultConfig { + applicationId = "ma.tkgraph.xtreamtv" + minSdk = 30 + targetSdk = 36 + versionCode = 23 + versionName = "2.0.23-login-success-nav" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + isMinifyEnabled = false + buildConfigField("boolean", "ENABLE_LOGGING", "true") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("boolean", "ENABLE_LOGGING", "false") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // --- Compose / TV --- + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.tv:tv-material:1.0.0") + implementation("androidx.tv:tv-foundation:1.0.0") + + // --- Activity / Lifecycle --- + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + + // --- Media3 ExoPlayer --- + implementation("androidx.media3:media3-exoplayer:1.5.1") + implementation("androidx.media3:media3-exoplayer-hls:1.5.1") + implementation("androidx.media3:media3-exoplayer-dash:1.5.1") + implementation("androidx.media3:media3-ui:1.5.1") + implementation("androidx.media3:media3-session:1.5.1") + + // --- Network --- + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // --- Images --- + implementation("io.coil-kt.coil3:coil-compose:3.0.4") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") + + // --- Security (EncryptedSharedPreferences) --- + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // --- EPG Cache (Room) --- + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + annotationProcessor("androidx.room:room-compiler:2.6.1") + + // --- Logging --- + implementation("com.jakewharton.timber:timber:5.0.1") + + // --- Core --- + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.leanback:leanback:1.2.0-alpha04") + + // ========== TESTS ========== + // Unit tests + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("io.mockk:mockk:1.13.14") + testImplementation("app.cash.turbine:turbine:1.2.0") + + // Android / UI tests + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.test:runner:1.6.2") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/docs/ERROR-HANDLING.md b/app/docs/ERROR-HANDLING.md new file mode 100644 index 0000000..7d3abe7 --- /dev/null +++ b/app/docs/ERROR-HANDLING.md @@ -0,0 +1,153 @@ +# Gestion d'erreurs — iMedia TV+ + +## Architecture + +```mermaid +flowchart TB + API[Serveur Xtream] -->|Timeout / 4xx / 5xx| Interceptor[OkHttp ErrorInterceptor] + Interceptor -->|Exception typée| Repository + Repository -->|Result / UiState.Error| ViewModel + ViewModel -->|Message localisé| UI[Écran Compose] + UI -->|Snackbar / Toast / Overlay| User[Utilisateur] +``` + +## Hiérarchie d'erreurs + +```kotlin +/** + * Toutes les erreurs Xtream sont des sealed classes + * pour un `when` exhaustif dans l'UI. + */ +sealed class XtreamException(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkException(message: String) : XtreamException(message) + class TimeoutException(message: String) : XtreamException(message) + class AuthException(message: String) : XtreamException(message) + class ServerException(val code: Int, message: String) : XtreamException(message) + class ParseException(message: String, cause: Throwable) : XtreamException(message, cause) + class StreamUnavailableException(message: String) : XtreamException(message) +} +``` + +## Table des erreurs + +| N° | Condition | Exception | Code UI | Message FR | Message EN | +|----|-----------|-----------|---------|------------|------------| +| 1 | Serveur injoignable | `NetworkException` | Erreur réseau | "Impossible de se connecter au serveur. Vérifiez votre réseau." | "Cannot reach server. Check your network." | +| 2 | Timeout dépassé (15s) | `TimeoutException` | Erreur réseau | "Le serveur ne répond pas. Réessayez plus tard." | "Server is not responding. Try again later." | +| 3 | Auth invalide | `AuthException` | Login bloqué | "Identifiants incorrects ou serveur invalide." | "Invalid credentials or server." | +| 4 | Flux vidéo cassé | `StreamUnavailableException` | Overlay erreur | "Flux indisponible pour cette chaîne." | "Stream unavailable for this channel." | +| 5 | HTTP 4xx | `ServerException` | Toast | "Erreur serveur (code X). Contactez votre fournisseur." | "Server error (code X). Contact your provider." | +| 6 | HTTP 5xx | `ServerException` | Toast | "Le serveur Xtream rencontre un problème." | "Xtream server is having issues." | +| 7 | JSON mal formé | `ParseException` | Fallback silencieux | "Données reçues incorrectes." | "Received data is malformed." | +| 8 | EPG vide | N/A (pas une erreur) | Texte vide | "Aucun programme trouvé pour cette chaîne." | "No program found for this channel." | +| 9 | Cache corrompu | N/A | Suppression + reload | "Mise à jour des données..." | "Updating data..." | + +## État loading global + +```kotlin +/** + * État unique pour TOUS les écrans. + * Permet un traitement uniforme dans les composants UI. + */ +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error( + val message: String, // Message affiché à l'utilisateur + val retry: Boolean = true, // Afficher un bouton "Réessayer" ? + val exception: XtreamException? = null // Pour logging uniquement + ) : UiState +} +``` + +## Intercepteur OkHttp + +```kotlin +class XtreamErrorInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return try { + val response = chain.proceed(request) + if (!response.isSuccessful) { + when (response.code) { + 401, 403 -> throw XtreamException.AuthException( + "Authentication failed" + ) + in 400..499 -> throw XtreamException.ServerException( + response.code, "Client error" + ) + in 500..599 -> throw XtreamException.ServerException( + response.code, "Server error" + ) + } + } + response + } catch (e: SocketTimeoutException) { + throw XtreamException.TimeoutException("Connection timed out") + } catch (e: ConnectException) { + throw XtreamException.NetworkException("Cannot connect") + } catch (e: UnknownHostException) { + throw XtreamException.NetworkException("DNS resolution failed") + } + } +} +``` + +## Comportement UI par erreur + +### Écran de connexion +``` +[Champs vides] +❌ Bouton Connexion désactivé + +[Erreur réseau] +✅ Bouton actif, message rouge sous le formulaire +✅ Champs conservés (pas de reset) + +[Auth refusée] +✅ Message : "Identifiants incorrects" +✅ Focus revient sur le champ mot de passe +``` + +### Écran Live TV +``` +[Erreur chargement chaînes] +✅ Toast 3s : "Impossible de charger les chaînes" +✅ Cache affiché si disponible + +[Chaîne sans flux] +✅ Overlay : "Flux indisponible" + bouton "Chaîne suivante" +``` + +### Lecteur vidéo +``` +[Flux mort pendant lecture] +✅ Remplacer par écran noir + message +✅ Bouton "Chaîne suivante" visible +✅ Retour possible vers le menu +``` + +## Règles de logging + +| Niveau | Usage | Exemple | +|--------|-------|---------| +| `Timber.d` | Debug uniquement (pas en prod) | "Loading 42 live channels" | +| `Timber.i` | Événements importants | "User logged in: user123" | +| `Timber.w` | Anomalies non bloquantes | "EPG cache expired for stream 99" | +| `Timber.e` | Erreurs utilisateur | "Stream unavailable for stream 42" | + +**❌ Interdit** : `Log.d("PASSWORD", password)` ou tout log contenant des credentials. + +## Annexes + +### Codes Xtream que l'on peut rencontrer + +| Code | Signification | Action | +|------|--------------|--------| +| `auth = "0"` | Compte invalide | Message login | +| `status = "Expired"` | Abonnement expiré | Message distinct | +| `status = "Disabled"` | Compte désactivé | Message distinct | +| `tv_archive = 0` | Pas d'archive | Cacher bouton Replay | +| `has_archive = false` | Pas d'archive | Cacher bouton Replay | +| Réponse vide | Serveur HS ou mauvaise URL | Erreur réseau générique | diff --git a/app/docs/SECURITY.md b/app/docs/SECURITY.md new file mode 100644 index 0000000..991bac1 --- /dev/null +++ b/app/docs/SECURITY.md @@ -0,0 +1,159 @@ +# Sécurité — iMedia TV+ + +## Principes + +1. **Aucun secret en clair sur le disque ou dans les logs** +2. **Chiffrement de bout en bout** du stockage local des credentials +3. **Isolation** : l'application ne partage pas les credentials avec d'autres apps +4. **Principle of least privilege** : permissions Android minimales + +## Stockage des credentials + +### Implémentation + +```kotlin +// data/storage/SecureStorage.kt +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class SecureStorage(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "imedia_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveCredentials(server: String, username: String, password: String) { + prefs.edit() + .putString("server_url", server) + .putString("username", username) + .putString("password", password) + .apply() + } + + fun getCredentials(): Credentials? { + val server = prefs.getString("server_url", null) + val username = prefs.getString("username", null) + val password = prefs.getString("password", null) + return if (server != null && username != null && password != null) { + Credentials(server, username, password) + } else null + } + + fun clearCredentials() { + prefs.edit().clear().apply() + } +} +``` + +Pour les favoris et la chaîne épinglée (données non sensibles), la version 1 utilisait des `SharedPreferences` classiques — acceptable. Si on veut tout unifier, on utilise `EncryptedSharedPreferences` partout. + +### Ce qui est chiffré + +| Donnée | Niveau | Sensibilité | +|--------|--------|-------------| +| Server URL | AES-256 GCM | Haute | +| Username | AES-256 GCM | Haute | +| Password | AES-256 GCM | Critique | +| Favoris | AES-256 SIV keys | Moyenne | +| Chaîne épinglée | AES-256 SIV keys | Basse | + +## Fichier de mapping des endpoints + +**Important :** les URLs Xtream construites avec le builder ne doivent pas non plus être logguées. + +```kotlin +// util/XtreamUrlBuilder.kt +class XtreamUrlBuilder(private val credentials: Credentials) { + + fun liveUrl(streamId: Int): String = + "${credentials.server}/live/${credentials.username}/${credentials.password}/$streamId.m3u8" + + // ⚠️ Attention : cette méthode est appelée par PlayerScreen + // — NE PAS logger son résultat +} +``` + +## Règles strictes + +### ✅ À faire + +- Utiliser `Timber.d` pour les logs de debug (supprimés en release par ProGuard) +- Vérifier les inputs utilisateur avant de les envoyer (URL bien formée) +- Timeout réseau configuré (15s connect, 30s read) +- Nettoyer les credentials à la déconnexion +- Minify + ProGuard en release (obfuscation) + +### ❌ Interdit + +```kotlin +// JAMAIS : +Log.d("Auth", "Login with $username / $password") +Timber.d("Server URL: $serverUrl") +println("Password is: $password") +Log.e("API", "Stream URL: ${urlBuilder.liveUrl(42)}") + +// En release, ces logs ne devraient pas survivre, +// mais ne prenez pas le risque en debug non plus. +``` + +## ProGuard + +```pro +# app/proguard-rules.pro +# Sécurité : ne pas exposer les données chiffrées +-keepclassmembers class ma.tkgraph.xtreamtv.data.storage.** { *; } + +# OkHttp / Retrofit déjà couverts par leurs propres rules +# Ne pas garder de logs en release +-assumenosideeffects class timber.log.Timber { + public static *** d(...); + public static *** v(...); +} +``` + +## Permissions Android + +```xml + + + + +``` + +L'application ne lit pas de fichiers locaux et ne sauvegarde que des données dans son propre espace de stockage privé — pas besoin de permissions de stockage. + +## HTTPS + +Bien que le protocole Xtream utilise souvent HTTP (non chiffré), l'application doit : +1. **Accepter HTTP** pour la compatibilité +2. **Préférer HTTPS** si le serveur le supporte +3. **Ne pas désactiver la validation** SSL/TLS (pas de `TrustAllCertificates`) + +Si le fournisseur Xtream le permet, les credentials transitent en HTTPS, ce qui protège le mot de passe en transit. + +## Audit + +Une amélioration future possible : `Sentry` ou `Firebase Crashlytics` pour monitorer les crashs. Attention cependant à ne pas envoyer de données sensibles dans les rapports : + +```kotlin +// Dans Application.onCreate() +FirebaseCrashlytics.getInstance().setCustomKey("screen", "login") +// ❌ PAS FirebaseCrashlytics.getInstance().setCustomKey("username", username) +``` + +## Checklist de sécurité avant release + +- [ ] Aucun log de credentials dans le code (grep "password" dans les fichiers source) +- [ ] EncryptedSharedPreferences en place, pas de fallback SharedPreferences +- [ ] Timeouts réseau configurés +- [ ] Permissions minimales (seulement INTERNET + ACCESS_NETWORK_STATE) +- [ ] ProGuard activé en release +- [ ] Pas de validation SSL désactivée +- [ ] Gestion de la déconnexion efface TOUTES les données diff --git a/app/gradle.properties b/app/gradle.properties new file mode 100644 index 0000000..5e845e5 --- /dev/null +++ b/app/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 -XX:+UseG1GC +org.gradle.parallel=false +org.gradle.configuration-cache=false +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e11132 --- /dev/null +++ b/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/app/gradlew b/app/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/app/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/app/gradlew.bat b/app/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/app/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..135b54a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,37 @@ +# --- iMedia TV+ ProGuard Rules --- + +# Timber — supprimer les logs debug en release +-assumenosideeffects class timber.log.Timber { + public static *** d(...); + public static *** v(...); + public static *** w(...); + public static *** i(...); +} + +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# Gson +-keepattributes Signature +-keep class com.google.gson.** { *; } +-keep class ma.tkgraph.xtreamtv.data.model.** { *; } + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + +# Coil +-keep class coil.** { *; } + +# Security Crypto +-keep class androidx.security.crypto.** { *; } diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts new file mode 100644 index 0000000..6cfbb17 --- /dev/null +++ b/app/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "iMediaTVPlus" +include(":app") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..89dab28 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt b/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt new file mode 100644 index 0000000..25a03d0 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv + +import android.content.Context +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.repository.AuthRepository +import ma.tkgraph.xtreamtv.data.repository.EpgRepository +import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository +import ma.tkgraph.xtreamtv.data.repository.LiveRepository +import ma.tkgraph.xtreamtv.data.repository.M3uRepository +import ma.tkgraph.xtreamtv.data.repository.RecentLiveRepository +import ma.tkgraph.xtreamtv.data.repository.SeriesRepository +import ma.tkgraph.xtreamtv.data.repository.UsageRepository +import ma.tkgraph.xtreamtv.data.repository.VodRepository +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor +import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * Container d'injection de dépendances manuel. + * Pas de Hilt/Dagger pour garder le projet léger. + */ +class AppContainer(context: Context) { + + val secureStorage = SecureStorage(context) + + val m3uRepository = M3uRepository(secureStorage) + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(XtreamBaseUrlInterceptor()) + .addInterceptor(XtreamErrorInterceptor()) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .writeTimeout(8, TimeUnit.SECONDS) + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl("http://localhost") // Sera remplacé dynamiquement + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java) + + // ─── Repositories ─── + val authRepository = AuthRepository(xtreamApi, secureStorage) + val usageRepository = UsageRepository(secureStorage) + val recentLiveRepository = RecentLiveRepository(context) + val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository, usageRepository) + val epgRepository = EpgRepository(xtreamApi, secureStorage) + val vodRepository = VodRepository(xtreamApi, secureStorage, usageRepository) + val seriesRepository = SeriesRepository(xtreamApi, secureStorage, usageRepository) + val favoritesRepository = FavoritesRepository(secureStorage) +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt b/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt new file mode 100644 index 0000000..dc84b6d --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt @@ -0,0 +1,145 @@ +package ma.tkgraph.xtreamtv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import ma.tkgraph.xtreamtv.ui.navigation.NavState +import ma.tkgraph.xtreamtv.ui.screens.GlobalSearchScreen +import ma.tkgraph.xtreamtv.ui.screens.HomeScreen +import ma.tkgraph.xtreamtv.ui.screens.LoginScreen +import ma.tkgraph.xtreamtv.ui.screens.MainMenuScreen +import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen +import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen +import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen +import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen +import ma.tkgraph.xtreamtv.ui.screens.VodScreen +import ma.tkgraph.xtreamtv.ui.screens.YoutubeScreen +import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme +import ma.tkgraph.xtreamtv.util.SessionManager + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntentExtras() + setContent { + XtreamTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AppNavigation() + } + } + } + } + + private fun handleIntentExtras() { + val serverUrl = intent.getStringExtra("server_url") + val username = intent.getStringExtra("xtream_user") + val password = intent.getStringExtra("xtream_pass") + if (serverUrl != null && username != null && password != null) { + XtreamApplication.instance.appContainer.secureStorage + .saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig( + serverUrl = serverUrl, + username = username, + password = password + )) + } + } +} + +/** + * Navigation principale. + * L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen + * qui vérifie le pin au chargement des streams. + */ +@Composable +fun AppNavigation() { + val sessionManager = remember { + SessionManager(XtreamApplication.instance.appContainer.secureStorage) + } + var hasSession by remember { + mutableStateOf(sessionManager.hasValidSession()) + } + var navState by remember { mutableStateOf(NavState.MainMenu) } + var hasAutoPlayedPin by remember { mutableStateOf(false) } + + if (!hasSession) { + LoginScreen( + onLoginSuccess = { + hasSession = true + navState = NavState.MainMenu + } + ) + return + } + + when (val state = navState) { + is NavState.MainMenu -> MainMenuScreen( + onOpenTv = { navState = NavState.Home() }, + onOpenVod = { navState = NavState.Vod() }, + onOpenSeries = { navState = NavState.Series() }, + onOpenSearch = { navState = NavState.GlobalSearch }, + onOpenYoutube = { navState = NavState.Youtube }, + onNavigate = { navState = it } + ) + + is NavState.GlobalSearch -> GlobalSearchScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu } + ) + + is NavState.Youtube -> YoutubeScreen( + onBack = { navState = NavState.MainMenu } + ) + + is NavState.Home -> HomeScreen( + focusStreamId = state.focusStreamId, + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu }, + hasAutoPlayedPin = hasAutoPlayedPin, + onAutoPlayPin = { hasAutoPlayedPin = true } + ) + + is NavState.Player -> PlayerScreen( + initialStream = state.stream, + allStreams = state.allStreams, + initialIndex = state.currentIndex, + replayUrl = state.replayUrl, + progressKey = state.progressKey, + progressKeys = state.progressKeys, + onBack = { + navState = state.returnState ?: NavState.Home() + } + ) + is NavState.ProgramGuide -> ProgramGuideScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu } + ) + + is NavState.ReplayGuide -> ReplayGuideScreen( + stream = state.stream, + onBack = { navState = NavState.Home(state.stream.streamId) }, + onPlayReplay = { url -> + navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url) + } + ) + + is NavState.Vod -> VodScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu } + ) + + is NavState.Series -> SeriesScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu } + ) + + is NavState.SeriesDetail -> SeriesScreen( + onNavigate = { navState = it }, + onBack = { navState = NavState.MainMenu } + ) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt b/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt new file mode 100644 index 0000000..cf5096a --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/XtreamApplication.kt @@ -0,0 +1,36 @@ +package ma.tkgraph.xtreamtv + +import android.app.Application +import timber.log.Timber + +/** + * Point d'entrée de l'application iMedia TV+. + * Configure le logging et l'injection de dépendances. + */ +class XtreamApplication : Application() { + + /** Container d'injection de dépendances (manual DI). */ + lateinit var appContainer: AppContainer + private set + + override fun onCreate() { + super.onCreate() + instance = this + appContainer = AppContainer(this) + initLogging() + } + + private fun initLogging() { + if (BuildConfig.ENABLE_LOGGING) { + Timber.plant(Timber.DebugTree()) + } else { + // En production : Crashlytics tree (à configurer) + // Timber.plant(CrashReportingTree()) + } + } + + companion object { + lateinit var instance: XtreamApplication + private set + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt new file mode 100644 index 0000000..4522838 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/AuthResponse.kt @@ -0,0 +1,23 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Réponse de l'API /player_api.php?username=...&password=... + */ +data class AuthResponse( + @SerializedName("user_info") val userInfo: UserInfo? = null, + val auth: String = "0", + val status: String = "" +) + +data class UserInfo( + val username: String = "", + @SerializedName("auth") val auth: Int = 0, + val status: String = "", + @SerializedName("created_at") val createdAt: String = "", + @SerializedName("exp_date") val expDate: String = "", + @SerializedName("is_trial") val isTrial: String = "0", + @SerializedName("max_connections") val maxConnections: String = "0", + @SerializedName("allowed_output_formats") val allowedFormats: List = emptyList() +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt new file mode 100644 index 0000000..8a29920 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt @@ -0,0 +1,55 @@ +package ma.tkgraph.xtreamtv.data.model + +import android.util.Base64 +import com.google.gson.annotations.SerializedName +import java.nio.charset.StandardCharsets + +data class EpgProgramDto( + @SerializedName("id") val id: String? = null, + @SerializedName("epg_id") val epgId: String? = null, + @SerializedName("title") val rawTitle: String? = null, + @SerializedName("description") val rawDescription: String? = null, + @SerializedName("start") val start: String? = null, + @SerializedName("end") val end: String? = null, + @SerializedName("start_timestamp") val startTimestamp: String? = null, + @SerializedName("stop_timestamp") val stopTimestamp: String? = null, + @SerializedName("has_archive") val hasArchive: Int? = null, + @SerializedName("now_playing") val nowPlaying: Int? = null, + @SerializedName("lang") val lang: String? = null +) { + fun titleText(): String { + return decodeMaybeBase64(rawTitle).ifBlank { "" } + } + + fun startMillis(): Long? { + startTimestamp?.toLongOrNull()?.let { return it * 1000L } + return null + } + + fun stopMillis(): Long? { + stopTimestamp?.toLongOrNull()?.let { return it * 1000L } + return null + } + + /** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */ + fun durationMinutes(): Int { + // Depuis les timestamps Unix (en secondes) + val startSec = startTimestamp?.toLongOrNull() ?: return 120 + val stopSec = stopTimestamp?.toLongOrNull() ?: return 120 + return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720) + } +} + +/** Décode si Base64, sinon retourne tel quel. */ +private fun decodeMaybeBase64(value: String?): String { + val text = value.orEmpty().trim() + if (text.isBlank()) return "" + return runCatching { + val decoded = Base64.decode(text, Base64.DEFAULT) + String(decoded, StandardCharsets.UTF_8).trim() + }.getOrNull() + ?.takeIf { decoded -> + decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() } + } + ?: text +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt new file mode 100644 index 0000000..54f2e21 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgShortResponse.kt @@ -0,0 +1,11 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +data class EpgShortResponse( + @SerializedName("epg_listings") val listings: List? = emptyList() +) + +data class EpgFullResponse( + @SerializedName("epg_listings") val listings: List? = emptyList() +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt new file mode 100644 index 0000000..08235a6 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt @@ -0,0 +1,12 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour une catégorie Live. + */ +data class LiveCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt new file mode 100644 index 0000000..7f67327 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveStreamDto.kt @@ -0,0 +1,24 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour un flux Live (chaîne TV). + */ +data class LiveStreamDto( + val num: Int = 0, + val name: String = "", + @SerializedName("stream_id") val streamId: Int = 0, + @SerializedName("stream_type") val streamType: String = "", + @SerializedName("stream_icon") val streamIcon: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("epg_channel_id") val epgChannelId: String? = null, + @SerializedName("tv_archive") val tvArchive: Int = 0, + @SerializedName("has_archive") val hasArchive: Boolean = false, + @SerializedName("container_extension") val containerExtension: String = "m3u8", + val added: String = "", + @SerializedName("custom_sid") val customSid: String? = null, + @SerializedName("tv_archive_duration") val archiveDuration: Int = 0, + /** URL directe du flux (utilisé en mode M3U). */ + val directUrl: String = "" +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt new file mode 100644 index 0000000..058acbc --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/SeriesDto.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour une série. + */ +data class SeriesDto( + val num: Int = 0, + val name: String = "", + @SerializedName("series_id") val seriesId: Int = 0, + val cover: String? = null, + val plot: String? = null, + val cast: String? = null, + val rating: String? = null, + val genre: String? = null, + val year: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("last_modified") val lastModified: String? = null +) + +data class SeriesCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) + +/** + * Détail d'une série (saisons + épisodes). + */ +data class SeriesInfoDto( + val seasons: List = emptyList(), + val episodes: Map> = emptyMap() +) + +data class SeasonDto( + val id: Int = 0, + @SerializedName("season_number") val seasonNumber: Int = 0, + val name: String = "" +) + +data class EpisodeDto( + val id: Int = 0, + @SerializedName("episode_num") val episodeNum: Int = 0, + val title: String? = null, + val name: String? = null, + @SerializedName("episode_name") val episodeName: String? = null, + @SerializedName("container_extension") val containerExtension: String = "mp4", + val info: EpisodeInfoDto? = null +) + +data class EpisodeInfoDto( + @SerializedName("movie_image") val movieImage: String? = null, + val plot: String? = null, + val duration: String? = null, + val rating: String? = null +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt new file mode 100644 index 0000000..14dac3f --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/UiState.kt @@ -0,0 +1,16 @@ +package ma.tkgraph.xtreamtv.data.model + +/** + * État générique pour tous les écrans. + * Usage : ViewModel expose `StateFlow>` + */ +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error( + val message: String, + val retry: Boolean = true, + val exception: XtreamException? = null + ) : UiState +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt new file mode 100644 index 0000000..9e2b4b8 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/VodDto.kt @@ -0,0 +1,29 @@ +package ma.tkgraph.xtreamtv.data.model + +import com.google.gson.annotations.SerializedName + +/** + * DTO pour un film VOD. + */ +data class VodDto( + val num: Int = 0, + val name: String = "", + @SerializedName("stream_id") val streamId: Int = 0, + @SerializedName("stream_type") val streamType: String = "movie", + @SerializedName("stream_icon") val streamIcon: String? = null, + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("container_extension") val containerExtension: String = "mp4", + val rating: String? = null, + val added: String = "", + val description: String? = null, + val year: String? = null, + val genre: String? = null, + val director: String? = null, + val cast: String? = null +) + +data class VodCategoryDto( + @SerializedName("category_id") val categoryId: String = "", + @SerializedName("category_name") val categoryName: String = "", + @SerializedName("parent_id") val parentId: Int = 0 +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt new file mode 100644 index 0000000..a9205b7 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamConfig.kt @@ -0,0 +1,23 @@ +package ma.tkgraph.xtreamtv.data.model + +/** + * Type de source : serveur Xtream ou fichier M3U. + */ +enum class SourceType { XTREAM, M3U } + +/** + * Configuration de connexion (Xtream ou M3U). + */ +data class XtreamConfig( + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val m3uUrl: String = "", + val sourceType: SourceType = SourceType.XTREAM +) { + /** Vrai si une configuration valide est présente (quel que soit le mode). */ + val isValid: Boolean get() = when (sourceType) { + SourceType.XTREAM -> serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank() + SourceType.M3U -> m3uUrl.isNotBlank() + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt new file mode 100644 index 0000000..47ad6c1 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/model/XtreamException.kt @@ -0,0 +1,25 @@ +package ma.tkgraph.xtreamtv.data.model + +import java.io.IOException + +/** + * Hiérarchie d'exceptions Xtream. + * Étend IOException pour être compatible avec OkHttp Interceptor. + */ +sealed class XtreamException( + message: String, + cause: Throwable? = null +) : IOException(message, cause) { + + class NetworkException(message: String) : XtreamException(message) + + class TimeoutException(message: String) : XtreamException(message) + + class AuthException(message: String) : XtreamException(message) + + class ServerException(val code: Int, message: String) : XtreamException(message) + + class ParseException(message: String, cause: Throwable) : XtreamException(message, cause) + + class StreamUnavailableException(message: String) : XtreamException(message) +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt new file mode 100644 index 0000000..4896f0a --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt @@ -0,0 +1,98 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.data.model.AuthResponse +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.EpgFullResponse +import ma.tkgraph.xtreamtv.data.model.EpgShortResponse +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Interface Retrofit pour l'API Xtream Codes. + * L'URL de base est définie dynamiquement par le Repository. + */ +interface XtreamApi { + + @GET("/player_api.php") + suspend fun authenticate( + @Query("username") username: String, + @Query("password") password: String + ): AuthResponse + + @GET("/player_api.php") + suspend fun getLiveCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_live_categories" + ): List + + @GET("/player_api.php") + suspend fun getLiveStreams( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_live_streams", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getShortEpg( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_short_epg", + @Query("stream_id") streamId: String, + @Query("limit") limit: Int = 8 + ): EpgShortResponse + + @GET("/player_api.php") + suspend fun getFullEpg( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_simple_data_table", + @Query("stream_id") streamId: String + ): EpgFullResponse + + @GET("/player_api.php") + suspend fun getVodCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_vod_categories" + ): List + + @GET("/player_api.php") + suspend fun getVodStreams( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_vod_streams", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getSeriesCategories( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series_categories" + ): List + + @GET("/player_api.php") + suspend fun getSeries( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series", + @Query("category_id") categoryId: Int? = null + ): List + + @GET("/player_api.php") + suspend fun getSeriesInfo( + @Query("username") username: String, + @Query("password") password: String, + @Query("action") action: String = "get_series_info", + @Query("series_id") seriesId: Int + ): SeriesInfoDto +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt new file mode 100644 index 0000000..680ff75 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamBaseUrlInterceptor.kt @@ -0,0 +1,80 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.XtreamApplication +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Intercepteur OkHttp qui réécrit l'URL de base de Retrofit + * avec l'URL du serveur Xtream stockée dans SecureStorage. + * + * Retrofit est initialisé avec "http://localhost" comme placeholder. + * Cet interceptor remplace "localhost" par le vrai serveur Xtream. + */ +class XtreamBaseUrlInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials() + + if (config == null || config.serverUrl.isBlank()) { + return chain.proceed(originalRequest) + } + + val serverUrl = normalize(config.serverUrl) + val newUrl = originalRequest.url.newBuilder() + .scheme(extractScheme(serverUrl)) + .host(extractHost(serverUrl)) + .port(extractPort(serverUrl)) + .build() + + val newRequest = originalRequest.newBuilder() + .url(newUrl) + .build() + + return chain.proceed(newRequest) + } + + private fun normalize(raw: String): String { + var value = raw.trim() + .replace("\\s".toRegex(), "") + .replace("\\u00A0", "") + + if (!value.startsWith("http://", ignoreCase = true) && + !value.startsWith("https://", ignoreCase = true) + ) { + value = "http://$value" + } + + val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true) + if (playerApiIndex >= 0) { + value = value.substring(0, playerApiIndex) + } + + return value.trimEnd('/') + } + + private fun extractScheme(url: String): String = + if (url.startsWith("https://", ignoreCase = true)) "https" else "http" + + private fun extractHost(url: String): String { + return url + .removePrefix("http://") + .removePrefix("https://") + .substringBefore("/") + .substringBefore(":") + .trim() + } + + private fun extractPort(url: String): Int { + val withoutProtocol = url + .removePrefix("http://") + .removePrefix("https://") + .substringBefore("/") + + val explicitPort = withoutProtocol.substringAfter(":", missingDelimiterValue = "") + .toIntOrNull() + + return explicitPort ?: if (extractScheme(url) == "https") 443 else 80 + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt new file mode 100644 index 0000000..682eeb7 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamErrorInterceptor.kt @@ -0,0 +1,51 @@ +package ma.tkgraph.xtreamtv.data.network + +import ma.tkgraph.xtreamtv.data.model.XtreamException +import okhttp3.Interceptor +import okhttp3.Response +import timber.log.Timber +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Intercepteur OkHttp qui transforme les erreurs réseau + * en exceptions typées pour un traitement uniforme. + */ +class XtreamErrorInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + return try { + val response = chain.proceed(request) + + when { + response.code == 401 || response.code == 403 -> + throw XtreamException.AuthException("Authentication failed") + response.code in 400..499 -> + throw XtreamException.ServerException( + response.code, "Client error: ${response.code}" + ) + response.code in 500..599 -> + throw XtreamException.ServerException( + response.code, "Server error: ${response.code}" + ) + } + + response + } catch (e: SocketTimeoutException) { + Timber.w("Network timeout: ${request.url}") + throw XtreamException.TimeoutException("Connection timed out") + } catch (e: ConnectException) { + Timber.w("Connection refused: ${request.url}") + throw XtreamException.NetworkException("Cannot connect to server") + } catch (e: UnknownHostException) { + Timber.w("DNS resolution failed for: ${request.url}") + throw XtreamException.NetworkException("DNS resolution failed") + } catch (e: XtreamException) { + // Déjà typé, on relance tel quel + throw e + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt new file mode 100644 index 0000000..9f45fea --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/AuthRepository.kt @@ -0,0 +1,156 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.model.XtreamException +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +/** + * Gère l'authentification et la session utilisateur. + */ +class AuthRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + + /** + * Tente de se connecter au serveur Xtream. + * + * Le premier login doit sauvegarder temporairement la config avant l'appel API + * parce que XtreamBaseUrlInterceptor lit l'URL serveur depuis SecureStorage. + */ + suspend fun login(serverUrl: String, username: String, password: String): UiState { + val cleanServerUrl = normalizeServerUrl(serverUrl) + val cleanUsername = username.trim() + val cleanPassword = password.trim() + val previousConfig = storage.getConfig() + val temporaryConfig = XtreamConfig( + sourceType = SourceType.XTREAM, + serverUrl = cleanServerUrl, + username = cleanUsername, + password = cleanPassword + ) + + return try { + Timber.i("Attempting login for user: %s on %s", cleanUsername, cleanServerUrl) + + storage.saveConfig(temporaryConfig) + + val response = api.authenticate(cleanUsername, cleanPassword) + + // Les serveurs Xtream retournent généralement : + // { "user_info": { "auth": 1, "status": "Active", ... }, "server_info": ... } + // L'ancien code lisait response.auth au niveau racine, qui est souvent absent. + // Résultat : faux "identifiants invalides". Merci le format sauvage. + val authValue = response.userInfo?.auth?.toString() + ?: response.auth + + val statusValue = response.userInfo?.status + ?: response.status + + val isAuthenticated = authValue == "1" || authValue.equals("true", ignoreCase = true) + val normalizedStatus = statusValue.trim() + + when { + !isAuthenticated -> { + Timber.w("Login failed: auth=%s status=%s", authValue, normalizedStatus) + restorePreviousConfig(previousConfig) + UiState.Error("Identifiants invalides ou format serveur non reconnu.", retry = true) + } + + normalizedStatus.isNotBlank() && !normalizedStatus.equals("Active", ignoreCase = true) -> { + Timber.w("Login failed: account status is %s", normalizedStatus) + restorePreviousConfig(previousConfig) + UiState.Error( + "Compte non actif. Statut : $normalizedStatus", + retry = false + ) + } + + else -> { + storage.saveConfig(temporaryConfig) + val info = UserInfo( + username = response.userInfo?.username?.takeIf { it.isNotBlank() } ?: cleanUsername, + status = normalizedStatus.ifBlank { "Active" }, + expDate = response.userInfo?.expDate?.takeIf { it.isNotBlank() }, + maxConnections = response.userInfo?.maxConnections?.takeIf { it.isNotBlank() } ?: "1", + isTrial = response.userInfo?.isTrial == "1" + ) + Timber.i("Login successful for: %s", cleanUsername) + UiState.Success(info) + } + } + } catch (e: XtreamException.TimeoutException) { + restorePreviousConfig(previousConfig) + UiState.Error("Le serveur ne répond pas. Vérifiez l'URL ou réessayez.", retry = true, exception = e) + } catch (e: XtreamException.NetworkException) { + restorePreviousConfig(previousConfig) + UiState.Error("Impossible de joindre le serveur. Vérifiez l'URL, le port et le réseau.", retry = true, exception = e) + } catch (e: XtreamException.AuthException) { + restorePreviousConfig(previousConfig) + UiState.Error("Identifiants refusés par le serveur.", retry = true, exception = e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error during login") + restorePreviousConfig(previousConfig) + UiState.Error("Connexion échouée : ${e.message ?: "erreur inconnue"}", retry = true) + } + } + + private fun restorePreviousConfig(previousConfig: XtreamConfig?) { + if (previousConfig != null) { + storage.saveConfig(previousConfig) + } else { + storage.clearConfigPreservingLocalData() + } + } + + private fun normalizeServerUrl(raw: String): String { + var value = raw.trim() + .replace("\\s".toRegex(), "") + .replace("\\u00A0", "") + + if (value.isBlank()) return value + + if (!value.startsWith("http://", ignoreCase = true) && + !value.startsWith("https://", ignoreCase = true) + ) { + value = "http://$value" + } + + val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true) + if (playerApiIndex >= 0) { + value = value.substring(0, playerApiIndex) + } + + return value.trimEnd('/') + } + + /** Sauvegarde une config M3U. */ + fun saveM3uConfig(m3uUrl: String) { + storage.saveConfig(XtreamConfig( + sourceType = SourceType.M3U, + m3uUrl = m3uUrl.trim() + )) + } + + /** Supprime la session et vide le cache M3U. */ + fun logout() { + storage.clearAll() + Timber.i("User logged out") + } + + fun hasSession(): Boolean = storage.hasConfig() + fun getSavedConfig(): XtreamConfig? = storage.getConfig() +} + +/** Informations utilisateur retournées après connexion. */ +data class UserInfo( + val username: String, + val status: String = "Active", + val expDate: String? = null, + val maxConnections: String = "1", + val isTrial: Boolean = false +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt new file mode 100644 index 0000000..ab2a467 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/EpgRepository.kt @@ -0,0 +1,41 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +class EpgRepository( + private val api: XtreamApi, + private val storage: SecureStorage +) { + private fun config(): XtreamConfig? = storage.getCredentials() + + suspend fun getShortEpg(streamId: Int): UiState> { + return try { + val c = config() ?: return UiState.Error("Not logged in") + val response = api.getShortEpg(c.username, c.password, streamId = streamId.toString(), limit = 8) + val programs = response.listings.orEmpty() + .map { it.copy(rawTitle = it.rawTitle) } // titleText() sera appelé côté UI + Timber.i("EPG short %d: %d programs", streamId, programs.size) + UiState.Success(programs) + } catch (e: Exception) { + Timber.w(e, "EPG short failed stream=%d", streamId) + UiState.Success(emptyList()) + } + } + + suspend fun getFullGuide(streamId: Int): UiState> { + return try { + val c = config() ?: return UiState.Error("Not logged in") + val response = api.getFullEpg(c.username, c.password, streamId = streamId.toString()) + val programs = response.listings.orEmpty() + UiState.Success(programs) + } catch (e: Exception) { + Timber.w(e, "EPG full failed stream=%d", streamId) + UiState.Success(emptyList()) + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt new file mode 100644 index 0000000..17113d9 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/ErrorHelpers.kt @@ -0,0 +1,26 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamException +import timber.log.Timber + +/** + * Helper pour enrober les appels réseau dans un bloc try-catch + * standardisé qui retourne UiState.Error pour toute exception. + */ +suspend fun catchErrors(block: suspend () -> UiState): UiState { + return try { + block() + } catch (e: XtreamException.TimeoutException) { + UiState.Error("Server is not responding. Try again later.", retry = true, exception = e) + } catch (e: XtreamException.NetworkException) { + UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e) + } catch (e: XtreamException.AuthException) { + UiState.Error("Authentication failed.", retry = true, exception = e) + } catch (e: XtreamException.ServerException) { + UiState.Error("Server error (${e.code}). Contact your provider.", retry = true, exception = e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error") + UiState.Error("An unexpected error occurred.", retry = true) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt new file mode 100644 index 0000000..d4de72b --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/FavoritesRepository.kt @@ -0,0 +1,65 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Gère les favoris pour Live, VOD et Séries. + * Délègue le stockage à SecureStorage. + */ +class FavoritesRepository(private val storage: SecureStorage) { + + // ─── Live Favorites ─── + fun getFavoriteLiveIds(): Set = storage.getFavoriteLiveIds() + + fun toggleFavoriteLive(streamId: Int): Boolean { + val current = getFavoriteLiveIds().toMutableSet() + return if (streamId in current) { + current.remove(streamId) + storage.setFavoriteLiveIds(current) + false // removed + } else { + current.add(streamId) + storage.setFavoriteLiveIds(current) + true // added + } + } + + fun isFavoriteLive(streamId: Int): Boolean = streamId in getFavoriteLiveIds() + + // ─── VOD Favorites ─── + fun getFavoriteVodIds(): Set = storage.getFavoriteVodIds() + + fun toggleFavoriteVod(streamId: Int): Boolean { + val current = getFavoriteVodIds().toMutableSet() + return if (streamId in current) { + current.remove(streamId) + storage.setFavoriteVodIds(current) + false + } else { + current.add(streamId) + storage.setFavoriteVodIds(current) + true + } + } + + // ─── Series Favorites ─── + fun getFavoriteSeriesIds(): Set = storage.getFavoriteSeriesIds() + + fun toggleFavoriteSeries(seriesId: Int): Boolean { + val current = getFavoriteSeriesIds().toMutableSet() + return if (seriesId in current) { + current.remove(seriesId) + storage.setFavoriteSeriesIds(current) + false + } else { + current.add(seriesId) + storage.setFavoriteSeriesIds(current) + true + } + } + + // ─── Pinned Channel ─── + fun getPinnedChannelId(): Int? = storage.getPinnedChannelId() + + fun setPinnedChannelId(streamId: Int?) = storage.setPinnedChannelId(streamId) +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt new file mode 100644 index 0000000..6efbf55 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt @@ -0,0 +1,73 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils +import timber.log.Timber + +/** + * Repository pour les flux Live TV. + * Délègue vers XtreamApi ou M3uParser selon la source configurée. + */ +class LiveRepository( + private val api: XtreamApi, + private val storage: SecureStorage, + private val m3uRepository: M3uRepository, + private val usageRepository: UsageRepository +) { + private fun requireConfig(): XtreamConfig? = storage.getConfig() + + suspend fun getCategories(): UiState> { + val config = requireConfig() ?: return UiState.Error("Not logged in") + return when (config.sourceType) { + SourceType.XTREAM -> getXtreamCategories(config) + SourceType.M3U -> when (val result = m3uRepository.getCategories()) { + is UiState.Success -> UiState.Success( + CategorySortUtils.sortLiveCategories(result.data, usageRepository.liveCategoryCounts()) + ) + else -> result + } + } + } + + suspend fun getStreams(categoryId: Int? = null): UiState> { + val config = requireConfig() ?: return UiState.Error("Not logged in") + return when (config.sourceType) { + SourceType.XTREAM -> getXtreamStreams(config, categoryId) + SourceType.M3U -> when (val result = m3uRepository.getStreams(categoryId)) { + is UiState.Success -> UiState.Success( + UsageSortUtils.sortByUsage(result.data, usageRepository.liveStreamCounts()) { it.streamId.toString() } + ) + else -> result + } + } + } + + private suspend fun getXtreamCategories(config: XtreamConfig): UiState> { + return try { + val categories = api.getLiveCategories(config.username, config.password) + val sorted = CategorySortUtils.sortLiveCategories(categories, usageRepository.liveCategoryCounts()) + UiState.Success(sorted) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch Xtream categories") + UiState.Error("Erreur de chargement des catégories") + } + } + + private suspend fun getXtreamStreams(config: XtreamConfig, categoryId: Int?): UiState> { + return try { + val streams = api.getLiveStreams(config.username, config.password, categoryId = categoryId) + val sorted = UsageSortUtils.sortByUsage(streams, usageRepository.liveStreamCounts()) { it.streamId.toString() } + UiState.Success(sorted) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch Xtream streams") + UiState.Error("Erreur de chargement des chaînes") + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt new file mode 100644 index 0000000..f678281 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt @@ -0,0 +1,151 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Parse un fichier M3U (Xtream-compatible) en listes de [LiveStreamDto]. + * + * Format supporté : + * #EXTM3U + * #EXTINF:-1 tvg-id="…" tvg-name="…" tvg-logo="…" group-title="Catégorie",Nom de la chaîne + * http://url-du-flux.m3u8 + * + * Chaque chaîne reçoit un streamId = hash de son URL (pour les favoris/pin). + */ +object M3uParser { + + private const val TIMEOUT_SECONDS = 15L + + private val client = OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .followRedirects(true) + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("User-Agent", "VLC/3.0.21 LibVLC/3.0.21") + .build() + chain.proceed(request) + } + .build() + + /** + * Télécharge et parse une playlist M3U. + * @param url URL distante ou locale du fichier M3U + * @return Pair (catégories, streams) ou null si échec + */ + suspend fun fetchAndParse(url: String): Pair, List>? { + return try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.w("M3U fetch failed: HTTP ${response.code}") + return null + } + val body = response.body?.string() ?: return null + parse(body) + } catch (e: Exception) { + Timber.e(e, "M3U fetch error: ${e.message}") + null + } + } + + /** + * Parse le contenu textuel d'un fichier M3U. + */ + fun parse(content: String): Pair, List> { + val categoryMap = mutableMapOf() // nom → id (hash) + val categories = mutableListOf() + val streams = mutableListOf() + + val lines = content.lines() + var i = 0 + var streamIdCounter = 1 + + while (i < lines.size) { + val line = lines[i].trim() + if (line.startsWith("#EXTINF:")) { + // Extraire les attributs de la ligne EXTINF + val attrs = parseExtInfAttributes(line) + + // L'URL est sur la ligne suivante (si elle existe et ne commence pas par #) + val urlLine = if (i + 1 < lines.size) lines[i + 1].trim() else "" + val streamUrl = if (urlLine.isNotBlank() && !urlLine.startsWith("#")) urlLine else "" + + if (streamUrl.isNotBlank()) { + val name = attrs["name"] ?: "Chaîne $streamIdCounter" + val logo = attrs["logo"] + val groupTitle = attrs["group-title"] + + // Générer un ID stable à partir de l'URL + val stableId = streamUrl.hashCode() + // Fallback si hash trop petit/négatif + val streamId = if (stableId > 0) stableId else streamIdCounter + + // Gérer les catégories + if (groupTitle != null && groupTitle !in categoryMap) { + val catId = categoryMap.size + 1 + categoryMap[groupTitle] = catId + categories.add(LiveCategoryDto( + categoryId = catId.toString(), + categoryName = groupTitle + )) + } + + val catIdStr = if (groupTitle != null) categoryMap[groupTitle]?.toString() ?: "" else "" + + streams.add(LiveStreamDto( + num = streamIdCounter, + name = name, + streamId = streamId, + streamType = "live", + streamIcon = logo, + categoryId = catIdStr, + containerExtension = streamUrl.substringAfterLast('.', "m3u8"), + directUrl = streamUrl + )) + streamIdCounter++ + i++ // Sauter la ligne URL + } + } + i++ + } + + Timber.i("M3U parsed: ${categories.size} categories, ${streams.size} streams") + return Pair(categories, streams) + } + + /** + * Extrait les attributs d'une ligne #EXTINF. + * Exemple : #EXTINF:-1 tvg-logo="https://…" group-title="Sports",Nom + */ + private fun parseExtInfAttributes(line: String): Map { + val attrs = mutableMapOf() + + // Extraire les paires clé="valeur" + val regex = Regex("""(\w[\w-]*)\s*=\s*"([^"]*)"""") + for (match in regex.findAll(line)) { + attrs[match.groupValues[1]] = match.groupValues[2] + } + + // Extraire le nom de la chaîne (après la dernière virgule) + val commaIdx = line.lastIndexOf(',') + if (commaIdx >= 0 && commaIdx + 1 < line.length) { + val name = line.substring(commaIdx + 1).trim() + if (name.isNotBlank()) { + attrs["name"] = name + } + } + + // Fallback sur tvg-name si le nom n'a pas été trouvé après la virgule + if (!attrs.containsKey("name") && attrs.containsKey("tvg-name")) { + attrs["name"] = attrs["tvg-name"]!! + } + + return attrs + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt new file mode 100644 index 0000000..2fae78c --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt @@ -0,0 +1,68 @@ +package ma.tkgraph.xtreamtv.data.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import timber.log.Timber + +/** + * Repository pour les playlists M3U. + */ +class M3uRepository(private val storage: SecureStorage) { + + private var cachedCategories: List = emptyList() + private var cachedStreams: List = emptyList() + private val fetchMutex = Mutex() + + /** Récupère les catégories depuis le M3U. */ + suspend fun getCategories(): UiState> { + val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré") + return UiState.Success(data.first) + } + + /** Récupère les streams depuis le M3U. */ + suspend fun getStreams(categoryId: Int? = null): UiState> { + val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré") + val streams = if (categoryId != null) { + data.second.filter { it.categoryId == categoryId.toString() } + } else { + data.second + } + return UiState.Success(streams) + } + + /** Invalide le cache M3U. */ + fun invalidateCache() { + cachedCategories = emptyList() + cachedStreams = emptyList() + } + + private suspend fun fetchIfNeeded(): Pair, List>? { + if (cachedStreams.isNotEmpty()) { + return Pair(cachedCategories, cachedStreams) + } + + return fetchMutex.withLock { + if (cachedStreams.isNotEmpty()) { + return@withLock Pair(cachedCategories, cachedStreams) + } + + val config = storage.getConfig() ?: return@withLock null + if (config.m3uUrl.isBlank()) return@withLock null + + Timber.i("Fetching M3U from: ${config.m3uUrl}") + val result = withContext(Dispatchers.IO) { + M3uParser.fetchAndParse(config.m3uUrl) + } ?: return@withLock null + + cachedCategories = result.first + cachedStreams = result.second + result + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt new file mode 100644 index 0000000..abf4b27 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt @@ -0,0 +1,258 @@ +package ma.tkgraph.xtreamtv.data.repository + +import android.content.Context +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.roundToInt + +/** + * Sauvegarde locale de la progression des films VOD et des épisodes de séries. + * + * Les clés sont volontairement simples : "vod:" ou "series::". + * Cela évite de mélanger deux vidéos, même si elles ont le même titre. + */ +class PlaybackProgressRepository(context: Context) { + + private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getProgress(key: String): Long = prefs.getLong(key, 0L) + + fun getPlaybackProgress(key: String): PlaybackProgressSnapshot { + val positionMs = prefs.getLong(key, 0L) + val durationMs = prefs.getLong(durationKey(key), 0L) + val completed = prefs.getBoolean(completedKey(key), false) + return PlaybackProgressSnapshot( + positionMs = positionMs, + durationMs = durationMs, + isCompleted = completed + ) + } + + fun saveProgress(key: String, positionMs: Long, durationMs: Long) { + if (positionMs < MIN_SAVED_POSITION_MS) { + clearProgress(key) + return + } + + val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs + val shouldMarkCompleted = normalizedDurationMs > 0L && + (normalizedDurationMs - positionMs <= FINISHED_THRESHOLD_MS || positionMs >= (normalizedDurationMs * FINISHED_RATIO).toLong()) + + if (shouldMarkCompleted) { + markCompleted(key, normalizedDurationMs) + } else { + prefs.edit() + .putLong(key, positionMs) + .putLong(lastWatchedKey(key), System.currentTimeMillis()) + .remove(completedKey(key)) + .apply { + if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs) + } + .apply() + } + } + + /** + * Stocke les métadonnées nécessaires pour afficher "Continuer à regarder" + * sans recharger tout le catalogue VOD/Séries. + */ + fun saveContinueMetadata( + key: String, + stream: LiveStreamDto, + allStreams: List = emptyList(), + currentIndex: Int = 0, + progressKeys: List = emptyList(), + playbackUrl: String? = null + ) { + val resolvedDirectUrl = playbackUrl?.takeIf { it.isNotBlank() } ?: stream.directUrl + val payload = JSONObject().apply { + put("title", stream.name) + put("imageUrl", stream.streamIcon.orEmpty()) + put("streamId", stream.streamId) + put("containerExtension", stream.containerExtension) + put("directUrl", resolvedDirectUrl) + put("currentIndex", currentIndex) + put("allStreams", JSONArray().apply { + allStreams.forEach { item -> put(item.toJson()) } + }) + put("progressKeys", JSONArray().apply { + progressKeys.forEach { item -> put(item) } + }) + } + prefs.edit().putString(metadataKey(key), payload.toString()).commit() + } + + fun getContinueMetadata(key: String): ContinueMetadata? { + val raw = prefs.getString(metadataKey(key), null) ?: return null + return runCatching { + val json = JSONObject(raw) + val allStreamsJson = json.optJSONArray("allStreams") ?: JSONArray() + val allStreams = buildList { + for (i in 0 until allStreamsJson.length()) { + allStreamsJson.optJSONObject(i)?.toLiveStreamDto()?.let { add(it) } + } + } + val progressKeysJson = json.optJSONArray("progressKeys") ?: JSONArray() + val progressKeys = buildList { + for (i in 0 until progressKeysJson.length()) { + add(progressKeysJson.optString(i)) + } + }.filter { it.isNotBlank() } + val fallbackStream = LiveStreamDto( + name = json.optString("title"), + streamId = json.optInt("streamId"), + streamIcon = json.optString("imageUrl").takeIf { it.isNotBlank() }, + containerExtension = json.optString("containerExtension", "mp4"), + directUrl = json.optString("directUrl").takeIf { it.isNotBlank() } ?: "" + ) + ContinueMetadata( + title = json.optString("title"), + imageUrl = json.optString("imageUrl").takeIf { it.isNotBlank() }, + stream = allStreams.getOrNull(json.optInt("currentIndex", 0)) ?: fallbackStream, + allStreams = allStreams, + currentIndex = json.optInt("currentIndex", 0).coerceAtLeast(0), + progressKeys = progressKeys + ) + }.getOrNull() + } + + fun markCompleted(key: String, durationMs: Long = 0L) { + val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs + prefs.edit() + .remove(key) + .remove(lastWatchedKey(key)) + .remove(metadataKey(key)) + .putBoolean(completedKey(key), true) + .apply { + if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs) + } + .apply() + } + + fun hasAnyProgressWithPrefix(prefix: String): Boolean { + return getInProgressKeys(prefix).isNotEmpty() + } + + fun getInProgressKeys(prefix: String? = null): List { + return prefs.all.keys + .asSequence() + .filter { key -> prefix == null || key.startsWith(prefix) } + .filterNot { key -> key.startsWith(METADATA_PREFIX) } + .filterNot { key -> key.endsWith(DURATION_SUFFIX) || key.endsWith(COMPLETED_SUFFIX) || key.endsWith(LAST_WATCHED_SUFFIX) } + .filter { key -> prefs.getLong(key, 0L) >= MIN_SAVED_POSITION_MS } + .filterNot { key -> prefs.getBoolean(completedKey(key), false) } + .sortedWith( + compareByDescending { key -> getLastWatched(key) } + .thenBy { key -> key } + ) + .toList() + } + + fun getLastWatched(key: String): Long = prefs.getLong(lastWatchedKey(key), 0L) + + fun getPlaybackProgress(primaryKey: String, fallbackKey: String): PlaybackProgressSnapshot { + val primary = getPlaybackProgress(primaryKey) + return if (primary.hasProgress || primary.isCompleted) primary else getPlaybackProgress(fallbackKey) + } + + fun migrateProgress(fromKey: String, toKey: String) { + if (fromKey == toKey) return + val existingProgress = getPlaybackProgress(toKey) + if (existingProgress.hasProgress || existingProgress.isCompleted) return + + val oldProgress = getPlaybackProgress(fromKey) + if (!oldProgress.hasProgress && !oldProgress.isCompleted) return + + val oldLastWatched = getLastWatched(fromKey) + val oldMetadata = prefs.getString(metadataKey(fromKey), null) + val editor = prefs.edit() + .remove(fromKey) + .remove(durationKey(fromKey)) + .remove(completedKey(fromKey)) + .remove(lastWatchedKey(fromKey)) + .remove(metadataKey(fromKey)) + + if (oldProgress.isCompleted) { + editor.putBoolean(completedKey(toKey), true) + } else { + editor.putLong(toKey, oldProgress.positionMs) + if (oldLastWatched > 0L) editor.putLong(lastWatchedKey(toKey), oldLastWatched) + if (oldMetadata != null) editor.putString(metadataKey(toKey), oldMetadata) + } + if (oldProgress.durationMs > 0L) { + editor.putLong(durationKey(toKey), oldProgress.durationMs) + } + editor.apply() + } + + fun clearProgress(key: String) { + prefs.edit() + .remove(key) + .remove(durationKey(key)) + .remove(lastWatchedKey(key)) + .remove(metadataKey(key)) + .apply() + } + + private fun durationKey(key: String): String = "$key$DURATION_SUFFIX" + private fun completedKey(key: String): String = "$key$COMPLETED_SUFFIX" + private fun lastWatchedKey(key: String): String = "$key$LAST_WATCHED_SUFFIX" + private fun metadataKey(key: String): String = "$METADATA_PREFIX$key" + + private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply { + put("name", name) + put("streamId", streamId) + put("streamIcon", streamIcon.orEmpty()) + put("containerExtension", containerExtension) + put("directUrl", directUrl) + } + + private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto( + name = optString("name"), + streamId = optInt("streamId"), + streamIcon = optString("streamIcon").takeIf { it.isNotBlank() }, + containerExtension = optString("containerExtension", "mp4"), + directUrl = optString("directUrl") + ) + + companion object { + private const val PREFS_NAME = "vod_series_playback_progress" + private const val DURATION_SUFFIX = ":duration" + private const val COMPLETED_SUFFIX = ":completed" + private const val LAST_WATCHED_SUFFIX = ":lastWatched" + private const val METADATA_PREFIX = "meta:" + private const val MIN_SAVED_POSITION_MS = 3_000L + private const val FINISHED_THRESHOLD_MS = 30_000L + private const val FINISHED_RATIO = 0.95f + } +} + +data class ContinueMetadata( + val title: String, + val imageUrl: String?, + val stream: LiveStreamDto, + val allStreams: List, + val currentIndex: Int, + val progressKeys: List +) + +data class PlaybackProgressSnapshot( + val positionMs: Long, + val durationMs: Long, + val isCompleted: Boolean = false +) { + val hasProgress: Boolean = !isCompleted && positionMs >= 3_000L + + val fraction: Float = when { + isCompleted -> 1f + hasProgress && durationMs > 0L -> (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + else -> 0f + } + + val percent: Int = when { + isCompleted -> 100 + fraction > 0f -> (fraction * 100f).roundToInt().coerceIn(1, 99) + else -> 0 + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt new file mode 100644 index 0000000..9dcbcdb --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt @@ -0,0 +1,102 @@ +package ma.tkgraph.xtreamtv.data.repository + +import android.content.Context +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import org.json.JSONArray +import org.json.JSONObject + +/** + * Stockage local très léger des dernières chaînes LIVE lancées. + * L'objectif est d'afficher une ligne rapide sur le menu principal sans recharger le catalogue. + */ +class RecentLiveRepository(context: Context) { + + private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun record(stream: LiveStreamDto) { + if (stream.streamId <= 0 || stream.name.isBlank()) return + + val now = System.currentTimeMillis() + val existing = getRecentStreams().filterNot { it.stream.streamId == stream.streamId }.toMutableList() + existing.add(0, RecentLiveEntry(stream = stream, lastUsedMs = now)) + + val payload = JSONArray().apply { + existing.take(MAX_RECENT).forEach { entry -> put(entry.toJson()) } + } + prefs.edit().putString(KEY_RECENT, payload.toString()).apply() + } + + fun getRecentStreams(limit: Int = MAX_RECENT): List { + val raw = prefs.getString(KEY_RECENT, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + array.optJSONObject(i)?.toRecentLiveEntry()?.let { add(it) } + } + } + .filter { it.stream.streamId > 0 && it.stream.name.isNotBlank() } + .distinctBy { it.stream.streamId } + .sortedByDescending { it.lastUsedMs } + .take(limit) + }.getOrElse { emptyList() } + } + + private fun RecentLiveEntry.toJson(): JSONObject = JSONObject().apply { + put("lastUsedMs", lastUsedMs) + put("stream", stream.toJson()) + } + + private fun JSONObject.toRecentLiveEntry(): RecentLiveEntry? { + val streamJson = optJSONObject("stream") ?: return null + return RecentLiveEntry( + stream = streamJson.toLiveStreamDto(), + lastUsedMs = optLong("lastUsedMs", 0L) + ) + } + + private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply { + put("num", num) + put("name", name) + put("streamId", streamId) + put("streamType", streamType) + put("streamIcon", streamIcon.orEmpty()) + put("categoryId", categoryId) + put("epgChannelId", epgChannelId.orEmpty()) + put("tvArchive", tvArchive) + put("hasArchive", hasArchive) + put("containerExtension", containerExtension) + put("added", added) + put("customSid", customSid.orEmpty()) + put("archiveDuration", archiveDuration) + put("directUrl", directUrl) + } + + private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto( + num = optInt("num", 0), + name = optString("name"), + streamId = optInt("streamId", 0), + streamType = optString("streamType"), + streamIcon = optString("streamIcon").takeIf { it.isNotBlank() }, + categoryId = optString("categoryId"), + epgChannelId = optString("epgChannelId").takeIf { it.isNotBlank() }, + tvArchive = optInt("tvArchive", 0), + hasArchive = optBoolean("hasArchive", false), + containerExtension = optString("containerExtension", "m3u8"), + added = optString("added"), + customSid = optString("customSid").takeIf { it.isNotBlank() }, + archiveDuration = optInt("archiveDuration", 0), + directUrl = optString("directUrl") + ) + + companion object { + private const val PREFS_NAME = "recent_live_channels" + private const val KEY_RECENT = "recent_live_items" + private const val MAX_RECENT = 12 + } +} + +data class RecentLiveEntry( + val stream: LiveStreamDto, + val lastUsedMs: Long +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt new file mode 100644 index 0000000..8fd08c0 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/SeriesRepository.kt @@ -0,0 +1,52 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils +import timber.log.Timber + +/** + * Repository pour les séries. + */ +class SeriesRepository( + private val api: XtreamApi, + private val storage: SecureStorage, + private val usageRepository: UsageRepository +) { + private fun requireConfig(): XtreamConfig? = storage.getCredentials() + + suspend fun getCategories(): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val cats = api.getSeriesCategories(config.username, config.password) + val sorted = CategorySortUtils.sortSeriesCategories(cats, usageRepository.seriesCategoryCounts()) + UiState.Success(sorted) + } + + suspend fun getSeriesList(categoryId: Int? = null): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val series = api.getSeries(config.username, config.password, categoryId = categoryId) + val sorted = UsageSortUtils.sortByUsage(series, usageRepository.seriesItemCounts()) { it.seriesId.toString() } + UiState.Success(sorted) + } + + suspend fun getSeriesInfo(seriesId: Int): UiState { + return try { + val config = requireConfig() ?: return UiState.Error("Not logged in") + val info = api.getSeriesInfo( + username = config.username, + password = config.password, + seriesId = seriesId + ) + UiState.Success(info) + } catch (e: Exception) { + Timber.e(e, "Failed to load series info for %d", seriesId) + UiState.Error("Failed to load series details.") + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt new file mode 100644 index 0000000..b5aac08 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/UsageRepository.kt @@ -0,0 +1,40 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Stocke et lit les compteurs d'utilisation pour trier l'IPTV comme un être humain + * qui ouvre toujours les mêmes chaînes, mais avec une télécommande. + */ +class UsageRepository(private val storage: SecureStorage) { + + fun recordLiveCategory(categoryId: Int?) = record(BUCKET_LIVE_CATEGORY, categoryId) + fun recordVodCategory(categoryId: Int?) = record(BUCKET_VOD_CATEGORY, categoryId) + fun recordSeriesCategory(categoryId: Int?) = record(BUCKET_SERIES_CATEGORY, categoryId) + + fun recordLiveStream(streamId: Int) = record(BUCKET_LIVE_STREAM, streamId) + fun recordVodItem(streamId: Int) = record(BUCKET_VOD_ITEM, streamId) + fun recordSeriesItem(seriesId: Int) = record(BUCKET_SERIES_ITEM, seriesId) + + fun liveCategoryCounts(): Map = storage.getUsageCounters(BUCKET_LIVE_CATEGORY) + fun vodCategoryCounts(): Map = storage.getUsageCounters(BUCKET_VOD_CATEGORY) + fun seriesCategoryCounts(): Map = storage.getUsageCounters(BUCKET_SERIES_CATEGORY) + + fun liveStreamCounts(): Map = storage.getUsageCounters(BUCKET_LIVE_STREAM) + fun vodItemCounts(): Map = storage.getUsageCounters(BUCKET_VOD_ITEM) + fun seriesItemCounts(): Map = storage.getUsageCounters(BUCKET_SERIES_ITEM) + + private fun record(bucket: String, id: Int?) { + if (id == null || id < 0) return + storage.incrementUsageCounter(bucket, id.toString()) + } + + companion object { + private const val BUCKET_LIVE_CATEGORY = "live_category" + private const val BUCKET_VOD_CATEGORY = "vod_category" + private const val BUCKET_SERIES_CATEGORY = "series_category" + private const val BUCKET_LIVE_STREAM = "live_stream" + private const val BUCKET_VOD_ITEM = "vod_item" + private const val BUCKET_SERIES_ITEM = "series_item" + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt new file mode 100644 index 0000000..b1a01f5 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/VodRepository.kt @@ -0,0 +1,36 @@ +package ma.tkgraph.xtreamtv.data.repository + +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils +import timber.log.Timber + +/** + * Repository pour les VOD (films). + */ +class VodRepository( + private val api: XtreamApi, + private val storage: SecureStorage, + private val usageRepository: UsageRepository +) { + private fun requireConfig(): XtreamConfig? = storage.getCredentials() + + suspend fun getCategories(): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val cats = api.getVodCategories(config.username, config.password) + val sorted = CategorySortUtils.sortVodCategories(cats, usageRepository.vodCategoryCounts()) + UiState.Success(sorted) + } + + suspend fun getMovies(categoryId: Int? = null): UiState> = catchErrors { + val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in") + val movies = api.getVodStreams(config.username, config.password, categoryId = categoryId) + val sorted = UsageSortUtils.sortByUsage(movies, usageRepository.vodItemCounts()) { it.streamId.toString() } + UiState.Success(sorted) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt new file mode 100644 index 0000000..428dbb5 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt @@ -0,0 +1,107 @@ +package ma.tkgraph.xtreamtv.data.repository + +import android.content.Context +import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.absoluteValue + +class YoutubeRepository(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getVideos(): List { + val raw = prefs.getString(KEY_VIDEOS, "[]").orEmpty() + return runCatching { + val array = JSONArray(raw) + buildList { + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + add( + YoutubeVideoItem( + id = item.optString("id"), + title = item.optString("title"), + url = item.optString("url"), + videoId = item.optString("videoId").takeIf { it.isNotBlank() }, + thumbnailUrl = item.optString("thumbnailUrl").takeIf { it.isNotBlank() }, + addedAtMs = item.optLong("addedAtMs", 0L), + lastPlayedAtMs = item.optLong("lastPlayedAtMs", 0L) + ) + ) + } + } + }.getOrDefault(emptyList()) + .filter { it.id.isNotBlank() && it.url.isNotBlank() } + .sortedWith(compareByDescending { it.lastPlayedAtMs }.thenByDescending { it.addedAtMs }) + } + + fun addVideo(title: String, input: String): Result { + val target = YoutubeUrlUtils.parse(input) + ?: return Result.failure(IllegalArgumentException("Lien ou code YouTube invalide")) + + val now = System.currentTimeMillis() + val id = target.videoId ?: stableId(target.normalizedUrl) + val normalizedTitle = title.trim().takeIf { it.isNotBlank() } + ?: target.videoId?.let { "YouTube $it" } + ?: "Vidéo YouTube" + + val newItem = YoutubeVideoItem( + id = id, + title = normalizedTitle, + url = target.normalizedUrl, + videoId = target.videoId, + thumbnailUrl = target.thumbnailUrl(), + addedAtMs = now, + lastPlayedAtMs = now + ) + + val merged = listOf(newItem) + getVideos().filterNot { it.id == id } + saveVideos(merged) + return Result.success(newItem) + } + + fun deleteVideo(id: String) { + saveVideos(getVideos().filterNot { it.id == id }) + } + + fun markPlayed(id: String) { + val now = System.currentTimeMillis() + saveVideos(getVideos().map { item -> + if (item.id == id) item.copy(lastPlayedAtMs = now) else item + }) + } + + private fun saveVideos(videos: List) { + val array = JSONArray() + videos.take(MAX_SAVED_VIDEOS).forEach { item -> + array.put( + JSONObject() + .put("id", item.id) + .put("title", item.title) + .put("url", item.url) + .put("videoId", item.videoId.orEmpty()) + .put("thumbnailUrl", item.thumbnailUrl.orEmpty()) + .put("addedAtMs", item.addedAtMs) + .put("lastPlayedAtMs", item.lastPlayedAtMs) + ) + } + prefs.edit().putString(KEY_VIDEOS, array.toString()).apply() + } + + private fun stableId(value: String): String = "yt_${value.hashCode().absoluteValue}" + + companion object { + private const val PREFS_NAME = "youtube_videos" + private const val KEY_VIDEOS = "videos" + private const val MAX_SAVED_VIDEOS = 80 + } +} + +data class YoutubeVideoItem( + val id: String, + val title: String, + val url: String, + val videoId: String?, + val thumbnailUrl: String?, + val addedAtMs: Long, + val lastPlayedAtMs: Long +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt b/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt new file mode 100644 index 0000000..d0af54a --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/data/storage/SecureStorage.kt @@ -0,0 +1,175 @@ +package ma.tkgraph.xtreamtv.data.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.XtreamConfig + +/** + * Stockage sécurisé des credentials avec EncryptedSharedPreferences. + * + * - Keys : AES-256 SIV + * - Values : AES-256 GCM + * - MasterKey générée via AndroidKeyStore + */ +class SecureStorage(context: Context) { + + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // ─── Config complète (Xtream ou M3U) ─── + + fun saveConfig(config: XtreamConfig) { + prefs.edit() + .putString(KEY_SOURCE_TYPE, config.sourceType.name) + .putString(KEY_SERVER, config.serverUrl) + .putString(KEY_USERNAME, config.username) + .putString(KEY_PASSWORD, config.password) + .putString(KEY_M3U_URL, config.m3uUrl) + .apply() + } + + fun getConfig(): XtreamConfig? { + // Essayer le nouveau format (avec sourceType) + val sourceTypeStr = prefs.getString(KEY_SOURCE_TYPE, null) + if (sourceTypeStr != null) { + val sourceType = try { SourceType.valueOf(sourceTypeStr) } catch (_: Exception) { SourceType.XTREAM } + return when (sourceType) { + SourceType.XTREAM -> { + val server = prefs.getString(KEY_SERVER, null) ?: return null + val username = prefs.getString(KEY_USERNAME, null) ?: return null + val password = prefs.getString(KEY_PASSWORD, null) ?: return null + XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password) + } + SourceType.M3U -> { + val m3uUrl = prefs.getString(KEY_M3U_URL, null) ?: return null + XtreamConfig(sourceType = SourceType.M3U, m3uUrl = m3uUrl) + } + } + } + // Fallback : ancien format (sans sourceType, session Xtream directe) + val server = prefs.getString(KEY_SERVER, null) ?: return null + val username = prefs.getString(KEY_USERNAME, null) ?: return null + val password = prefs.getString(KEY_PASSWORD, null) ?: return null + return XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password) + } + + /** Retourne la config en ignorant le type (pour les migrations). */ + fun getCredentials(): XtreamConfig? = getConfig() + + fun hasConfig(): Boolean = prefs.contains(KEY_SOURCE_TYPE) + + fun clearAll() { + prefs.edit().clear().apply() + } + + /** + * Efface uniquement la configuration de connexion. + * Garde les favoris, l'historique local, les compteurs d'usage, etc. + */ + fun clearConfigPreservingLocalData() { + prefs.edit() + .remove(KEY_SOURCE_TYPE) + .remove(KEY_SERVER) + .remove(KEY_USERNAME) + .remove(KEY_PASSWORD) + .remove(KEY_M3U_URL) + .apply() + } + + // ─── Favorites (Live) ─── + + fun getFavoriteLiveIds(): Set = + prefs.getStringSet(KEY_FAV_LIVE, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteLiveIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_LIVE, ids.map { it.toString() }.toSet()).apply() + } + + // ─── Favorites (VOD) ─── + + fun getFavoriteVodIds(): Set = + prefs.getStringSet(KEY_FAV_VOD, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteVodIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_VOD, ids.map { it.toString() }.toSet()).apply() + } + + // ─── Favorites (Series) ─── + + fun getFavoriteSeriesIds(): Set = + prefs.getStringSet(KEY_FAV_SERIES, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() ?: emptySet() + + fun setFavoriteSeriesIds(ids: Set) { + prefs.edit().putStringSet(KEY_FAV_SERIES, ids.map { it.toString() }.toSet()).apply() + } + + + // ─── Usage counters (tri par utilisation) ─── + + fun incrementUsageCounter(bucket: String, id: String) { + val cleanId = id.trim() + if (bucket.isBlank() || cleanId.isBlank()) return + val current = getUsageCounters(bucket).toMutableMap() + val nextValue = (current[cleanId] ?: 0) + 1 + current[cleanId] = nextValue.coerceAtMost(999_999) + prefs.edit() + .putStringSet(usageKey(bucket), current.map { (key, value) -> "$key=$value" }.toSet()) + .commit() + } + + fun getUsageCounters(bucket: String): Map { + if (bucket.isBlank()) return emptyMap() + return prefs.getStringSet(usageKey(bucket), emptySet()) + ?.mapNotNull { raw -> + val key = raw.substringBefore('=', missingDelimiterValue = "").trim() + val value = raw.substringAfter('=', missingDelimiterValue = "").toIntOrNull() + if (key.isNotBlank() && value != null) key to value else null + } + ?.toMap() ?: emptyMap() + } + + private fun usageKey(bucket: String): String = "usage_counter_$bucket" + + // ─── Pinned Channel ─── + + fun getPinnedChannelId(): Int? { + val id = prefs.getString(KEY_PINNED, null) ?: return null + return id.toIntOrNull() + } + + fun setPinnedChannelId(streamId: Int?) { + prefs.edit().putString(KEY_PINNED, streamId?.toString()).apply() + } + + companion object { + private const val PREFS_NAME = "imedia_secure_prefs" + + private const val KEY_SOURCE_TYPE = "source_type" + private const val KEY_SERVER = "server_url" + private const val KEY_USERNAME = "username" + private const val KEY_PASSWORD = "password" + private const val KEY_M3U_URL = "m3u_url" + private const val KEY_FAV_LIVE = "favorite_live" + private const val KEY_FAV_VOD = "favorite_vod" + private const val KEY_FAV_SERIES = "favorite_series" + private const val KEY_PINNED = "pinned_channel" + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt new file mode 100644 index 0000000..d295268 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt @@ -0,0 +1,103 @@ +package ma.tkgraph.xtreamtv.ui.components + +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type + +private val TvKeyPageUp = Key(AndroidKeyEvent.KEYCODE_PAGE_UP.toLong()) +private val TvKeyPageDown = Key(AndroidKeyEvent.KEYCODE_PAGE_DOWN.toLong()) +private val TvKeyChannelUp = Key(AndroidKeyEvent.KEYCODE_CHANNEL_UP.toLong()) +private val TvKeyChannelDown = Key(AndroidKeyEvent.KEYCODE_CHANNEL_DOWN.toLong()) + +/** + * Raccourcis Android TV pour les longues listes. + * + * Compatible avec les versions Compose qui n'exposent pas nativeKeyEvent. + * - Appui long Haut : début de liste + * - Appui long Bas : fin de liste + * - Channel+/PageUp : saut vers le haut + * - Channel-/PageDown: saut vers le bas + */ +fun Modifier.tvFastScrollKeys( + enabled: Boolean = true, + onJumpToTop: () -> Unit, + onJumpToBottom: () -> Unit, + onPageUp: () -> Unit, + onPageDown: () -> Unit +): Modifier = composed { + if (!enabled) return@composed this + + var upKeyDownCount by remember { mutableIntStateOf(0) } + var downKeyDownCount by remember { mutableIntStateOf(0) } + + this.onPreviewKeyEvent { event -> + when (event.type) { + KeyEventType.KeyDown -> { + when (event.key) { + TvKeyChannelUp, TvKeyPageUp -> { + upKeyDownCount = 0 + downKeyDownCount = 0 + onPageUp() + true + } + + TvKeyChannelDown, TvKeyPageDown -> { + upKeyDownCount = 0 + downKeyDownCount = 0 + onPageDown() + true + } + + Key.DirectionUp -> { + upKeyDownCount += 1 + downKeyDownCount = 0 + + // Le premier KeyDown reste la navigation normale. + // Les répétitions envoyées pendant l'appui long déclenchent le saut direct. + if (upKeyDownCount >= 2) { + onJumpToTop() + true + } else { + false + } + } + + Key.DirectionDown -> { + downKeyDownCount += 1 + upKeyDownCount = 0 + + if (downKeyDownCount >= 2) { + onJumpToBottom() + true + } else { + false + } + } + + else -> { + upKeyDownCount = 0 + downKeyDownCount = 0 + false + } + } + } + + KeyEventType.KeyUp -> { + if (event.key == Key.DirectionUp) upKeyDownCount = 0 + if (event.key == Key.DirectionDown) downKeyDownCount = 0 + false + } + + else -> false + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt new file mode 100644 index 0000000..5ab9701 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Modifier qui ajoute un contour lumineux visible quand l'élément ou un de ses enfants a le focus. + * Idéal pour la navigation télécommande Android TV. + * + * @param focusedBorderColor Couleur du contour quand focusé + * @param unfocusedBorderColor Couleur du contour quand pas focusé (transparent par défaut) + * @param borderWidth Épaisseur du contour + * @param cornerRadius Arrondi des coins + */ +fun Modifier.tvFocusHighlight( + focusedBorderColor: Color = Color(0xFFE50914), + unfocusedBorderColor: Color = Color.Transparent, + borderWidth: Dp = 3.dp, + cornerRadius: Dp = 12.dp +): Modifier = composed { + var isFocused by remember { mutableStateOf(false) } + + val animatedAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(durationMillis = 150) + ) + + this + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .then( + if (isFocused) { + Modifier.border( + width = borderWidth, + color = focusedBorderColor.copy(alpha = animatedAlpha), + shape = RoundedCornerShape(cornerRadius) + ) + } else { + Modifier.border( + width = borderWidth, + color = unfocusedBorderColor, + shape = RoundedCornerShape(cornerRadius) + ) + } + ) +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt new file mode 100644 index 0000000..567bd60 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt @@ -0,0 +1,658 @@ +package ma.tkgraph.xtreamtv.ui.components + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key as ComposeKey +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Commande envoyée depuis PlayerScreen vers le mini lecteur YouTube. + * Le nonce force Compose à rejouer la commande même si le même bouton est pressé plusieurs fois. + */ +data class YoutubeRemoteCommand( + val type: YoutubeCommandType, + val nonce: Long = System.nanoTime() +) + +enum class YoutubeCommandType { + TogglePlayPause, + SeekBackward, + SeekForward +} + +private enum class YoutubeLoadMode { + Embed, + Site +} + +/** + * Mémoire légère de session : garde le dernier lien saisi tant que le processus + * de l'application reste ouvert. Rien n'est écrit sur disque. + */ +private object YoutubeMiniPlayerSessionMemory { + var lastInputText: String = DEFAULT_YOUTUBE_WATCH_PREFIX +} + +private data class YoutubeEmbedTarget( + val normalizedUrl: String, + val videoId: String?, + val playlistId: String?, + val channelOrSiteUrl: String? +) { + val canUseEmbed: Boolean = videoId != null || playlistId != null +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun YoutubeMiniPlayer( + modifier: Modifier = Modifier, + remoteCommand: YoutubeRemoteCommand? = null, + onClose: () -> Unit +) { + val inputFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val loadButtonFocusRequester = remember { FocusRequester() } + val minimizeButtonFocusRequester = remember { FocusRequester() } + val closeButtonFocusRequester = remember { FocusRequester() } + val minimizedFocusRequester = remember { FocusRequester() } + var isInputFocused by remember { mutableStateOf(false) } + var inputUrl by rememberSaveable(stateSaver = TextFieldValue.Saver) { + val rememberedInput = YoutubeMiniPlayerSessionMemory.lastInputText + .takeIf { it.isNotBlank() } + ?: DEFAULT_YOUTUBE_WATCH_PREFIX + mutableStateOf( + TextFieldValue( + text = rememberedInput, + selection = TextRange(rememberedInput.length) + ) + ) + } + var loadedUrl by rememberSaveable { mutableStateOf("") } + var loadModeName by rememberSaveable { mutableStateOf(YoutubeLoadMode.Embed.name) } + var status by remember { mutableStateOf("V8 focus : ajoutez le code puis Charger") } + var isMinimized by rememberSaveable { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + val loadMode = remember(loadModeName) { + runCatching { YoutubeLoadMode.valueOf(loadModeName) }.getOrDefault(YoutubeLoadMode.Embed) + } + val target = remember(loadedUrl) { parseYoutubeTarget(loadedUrl) } + + fun exitInputAndFocusLoadButton() { + focusManager.clearFocus(force = true) + runCatching { loadButtonFocusRequester.requestFocus() } + status = "Champ quitté : OK sur Charger" + } + + DisposableEffect(Unit) { + onDispose { + webView?.stopLoading() + webView?.destroy() + webView = null + } + } + + BackHandler(enabled = !isMinimized) { + if (isInputFocused) { + exitInputAndFocusLoadButton() + } else { + onClose() + } + } + + BackHandler(enabled = isMinimized) { + onClose() + } + + fun sendCommand(type: YoutubeCommandType) { + val js = when (type) { + YoutubeCommandType.TogglePlayPause -> "if(window.togglePlay){togglePlay();}" + YoutubeCommandType.SeekBackward -> "if(window.seekBy){seekBy(-10);}" + YoutubeCommandType.SeekForward -> "if(window.seekBy){seekBy(10);}" + } + webView?.evaluateJavascript(js, null) + } + + fun loadUrl(mode: YoutubeLoadMode) { + val normalized = normalizeYoutubeUrl(inputUrl.text) + YoutubeMiniPlayerSessionMemory.lastInputText = inputUrl.text + loadedUrl = normalized + loadModeName = mode.name + status = when { + normalized.isBlank() -> "Ajoutez le code vidéo après watch?v=" + mode == YoutubeLoadMode.Site -> "Chargement du site YouTube" + else -> "Chargement du player intégré" + } + } + + fun restoreFromMinimized() { + isMinimized = false + status = "Mode normal : OK = Play/Pause" + } + + LaunchedEffect(remoteCommand) { + remoteCommand?.let { sendCommand(it.type) } + } + + LaunchedEffect(Unit) { + runCatching { inputFocusRequester.requestFocus() } + } + + LaunchedEffect(isMinimized) { + if (isMinimized) { + status = "Mode minimisé : OK pour agrandir" + runCatching { minimizedFocusRequester.requestFocus() } + } else { + runCatching { inputFocusRequester.requestFocus() } + } + } + + val surfaceModifier = modifier.then( + if (isMinimized) { + Modifier + .width(220.dp) + .alpha(0.30f) + .focusRequester(minimizedFocusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && (event.key == Key.DirectionCenter || event.key == Key.Enter)) { + restoreFromMinimized() + true + } else if (event.type == KeyEventType.KeyUp && event.key == Key.Back) { + onClose() + true + } else { + false + } + } + } else { + Modifier + .width(520.dp) + .onPreviewKeyEvent { event -> + when { + // Filet de sécurité au niveau du conteneur. + isInputFocused && (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && + (event.key == Key.DirectionRight || event.key == Key.DirectionDown || event.key == Key.Tab || event.key == Key.Back) -> { + if (event.type == KeyEventType.KeyDown) { + exitInputAndFocusLoadButton() + } + true + } + + // OK depuis le champ charge directement, utile sur les Mi Box qui gardent le focus texte. + isInputFocused && event.type == KeyEventType.KeyDown && + (event.key == Key.DirectionCenter || event.key == Key.Enter) -> { + loadUrl(YoutubeLoadMode.Embed) + exitInputAndFocusLoadButton() + true + } + + event.type == KeyEventType.KeyUp && event.key == Key.Back -> { + onClose() + true + } + + else -> false + } + } + } + ) + + Surface( + modifier = surfaceModifier, + color = Color.Black.copy(alpha = if (isMinimized) 0.34f else 0.88f), + shape = RoundedCornerShape(if (isMinimized) 14.dp else 18.dp), + shadowElevation = if (isMinimized) 2.dp else 10.dp + ) { + Column( + modifier = Modifier + .border( + 1.dp, + Color(0xFF00D5FF).copy(alpha = if (isMinimized) 0.16f else 0.45f), + RoundedCornerShape(if (isMinimized) 14.dp else 18.dp) + ) + .padding(if (isMinimized) 5.dp else 12.dp), + verticalArrangement = Arrangement.spacedBy(if (isMinimized) 0.dp else 8.dp) + ) { + if (!isMinimized) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "YouTube discret · V8", + color = Color.White, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "TV muette · OK Play/Pause · Retour fermer", + color = Color(0xFFB8C7D1), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton( + onClick = onClose, + modifier = Modifier.focusRequester(closeButtonFocusRequester) + ) { + Text("Fermer", color = Color.White) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = inputUrl, + onValueChange = { + inputUrl = it + YoutubeMiniPlayerSessionMemory.lastInputText = it.text + }, + modifier = Modifier + .weight(1f) + .focusRequester(inputFocusRequester) + .onFocusChanged { isInputFocused = it.isFocused } + .onPreviewKeyEvent { event -> + val exitKeys = event.key == Key.DirectionRight || + event.key == Key.DirectionDown || + event.key == Key.Tab || + event.key == Key.Back + val loadKeys = event.key == Key.DirectionCenter || event.key == Key.Enter + + when { + (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && exitKeys -> { + if (event.type == KeyEventType.KeyDown) { + exitInputAndFocusLoadButton() + } + true + } + event.type == KeyEventType.KeyDown && loadKeys -> { + loadUrl(YoutubeLoadMode.Embed) + exitInputAndFocusLoadButton() + true + } + else -> false + } + }, + singleLine = true, + label = { Text("Lien ou code YouTube") }, + placeholder = { Text("CTGbG3bygLg") } + ) + Button( + onClick = { loadUrl(YoutubeLoadMode.Embed) }, + modifier = Modifier.focusRequester(loadButtonFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00A9D6)) + ) { + Text("Charger") + } + Button( + onClick = { isMinimized = true }, + modifier = Modifier.focusRequester(minimizeButtonFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF44515A)) + ) { + Text("Minimiser") + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (isMinimized) 112.dp else 240.dp) + .background(Color.Black, RoundedCornerShape(if (isMinimized) 10.dp else 12.dp)), + contentAlignment = Alignment.Center + ) { + if (loadedUrl.isBlank()) { + Text("Aucun lien chargé", color = Color(0xFFB8C7D1)) + } else { + ComposeKey("hermes-youtube-webview") { + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(false) + settings.userAgentString = DESKTOP_USER_AGENT + + runCatching { + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + } + + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + val message = consoleMessage?.message().orEmpty() + if (message.startsWith("HERMES_YT:")) { + status = message.removePrefix("HERMES_YT:") + return true + } + return super.onConsoleMessage(consoleMessage) + } + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + return false + } + + override fun onPageFinished(view: WebView?, url: String?) { + if (!status.startsWith("YouTube erreur") && !isMinimized) { + status = if (loadMode == YoutubeLoadMode.Site) { + "Site YouTube chargé : cliquez Play si nécessaire" + } else { + "Player chargé : OK = Play/Pause" + } + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + if (request?.isForMainFrame == true) { + status = "Erreur WebView : ${error?.description ?: "chargement impossible"}" + } + } + } + setBackgroundColor(android.graphics.Color.BLACK) + }.also { webView = it } + }, + update = { view -> + webView = view + val currentTarget = target + val tagValue = "${loadedUrl}|${loadMode.name}" + if (view.tag != tagValue) { + view.tag = tagValue + when { + currentTarget == null -> Unit + loadMode == YoutubeLoadMode.Site -> { + view.loadUrl( + currentTarget.toWatchOrNormalizedUrl(), + youtubeHeaders() + ) + } + currentTarget.canUseEmbed -> { + view.loadDataWithBaseURL( + HERMES_YOUTUBE_ORIGIN, + buildYoutubeStaticIframeHtml(currentTarget), + "text/html", + "utf-8", + currentTarget.toEmbedUrl() + ) + } + else -> { + view.loadUrl( + currentTarget.toWatchOrNormalizedUrl(), + youtubeHeaders() + ) + } + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + + if (isMinimized) { + Button( + onClick = { restoreFromMinimized() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(3.dp) + .focusRequester(minimizedFocusRequester), + colors = ButtonDefaults.buttonColors(containerColor = Color.Black.copy(alpha = 0.58f)) + ) { + Text("OK agrandir", color = Color.White) + } + } + } + + if (!isMinimized) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { sendCommand(YoutubeCommandType.SeekBackward) }) { Text("-10s") } + Button(onClick = { sendCommand(YoutubeCommandType.TogglePlayPause) }) { Text("Pause / Play") } + Button(onClick = { sendCommand(YoutubeCommandType.SeekForward) }) { Text("+10s") } + Spacer(Modifier.width(8.dp)) + Text( + text = status, + color = Color(0xFFB8C7D1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +private const val DEFAULT_YOUTUBE_WATCH_PREFIX = "https://www.youtube.com/watch?v=" +private val YOUTUBE_VIDEO_ID_PATTERN = Regex("^[A-Za-z0-9_-]{8,20}$") + +private const val HERMES_YOUTUBE_ORIGIN = "https://hermes-youtube.local/" +private const val DESKTOP_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +private fun youtubeHeaders(): Map = mapOf( + "Referer" to HERMES_YOUTUBE_ORIGIN, + "Origin" to HERMES_YOUTUBE_ORIGIN.trimEnd('/') +) + +private fun normalizeYoutubeUrl(raw: String): String { + val value = raw.trim() + if (value.isBlank() || value == DEFAULT_YOUTUBE_WATCH_PREFIX) return "" + + return when { + value.startsWith("http://") || value.startsWith("https://") -> value + value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value" + value.startsWith("watch?v=") -> "https://www.youtube.com/$value" + value.startsWith("v=") -> DEFAULT_YOUTUBE_WATCH_PREFIX + value.removePrefix("v=") + YOUTUBE_VIDEO_ID_PATTERN.matches(value) -> DEFAULT_YOUTUBE_WATCH_PREFIX + value + else -> "https://$value" + } +} + +private fun parseYoutubeTarget(rawUrl: String): YoutubeEmbedTarget? { + val normalized = normalizeYoutubeUrl(rawUrl) + if (normalized.isBlank()) return null + + return runCatching { + val uri = Uri.parse(normalized) + val host = uri.host.orEmpty().lowercase() + val pathSegments = uri.pathSegments + + val videoId = when { + host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull() + uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v") + pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1] + else -> null + }?.takeIf { it.length in 8..20 } + + val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() } + + YoutubeEmbedTarget( + normalizedUrl = normalized, + videoId = videoId, + playlistId = playlistId, + channelOrSiteUrl = normalized + ) + }.getOrNull() +} + +private fun YoutubeEmbedTarget.toEmbedUrl(): String { + val origin = HERMES_YOUTUBE_ORIGIN.trimEnd('/').urlEncode() + val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin" + return when { + videoId != null -> "https://www.youtube.com/embed/$videoId?$common" + playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${playlistId.urlEncode()}&$common" + else -> normalizedUrl + } +} + +private fun YoutubeEmbedTarget.toWatchOrNormalizedUrl(): String { + return when { + videoId != null -> "https://www.youtube.com/watch?v=$videoId" + else -> channelOrSiteUrl ?: normalizedUrl + } +} + +private fun buildYoutubeStaticIframeHtml(target: YoutubeEmbedTarget): String { + val embedUrl = target.toEmbedUrl().escapeHtml() + + return """ + + + + + + + + + + + + + + """.trimIndent() +} + +private fun String.escapeHtml(): String = + replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + +private fun String.urlEncode(): String = Uri.encode(this) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt new file mode 100644 index 0000000..5de6a04 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt @@ -0,0 +1,51 @@ +package ma.tkgraph.xtreamtv.ui.navigation + +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto + +/** + * État de navigation de l'application. + * Chaque écran est un état avec ses propres données contextuelles. + */ +sealed class NavState { + /** Menu d’accueil principal : TV / VOD / Séries. */ + data object MainMenu : NavState() + + /** Écran TV Live avec catégories et chaînes. */ + data class Home(val focusStreamId: Int? = null) : NavState() + + /** Lecteur vidéo. */ + data class Player( + val stream: LiveStreamDto, + val allStreams: List = emptyList(), + val currentIndex: Int = 0, + val replayUrl: String? = null, + val returnState: NavState? = null, + val progressKey: String? = null, + val progressKeys: List = emptyList() + ) : NavState() + + /** Recherche globale LIVE / VOD / Séries. */ + data object GlobalSearch : NavState() + + /** Catalogue local de vidéos YouTube. */ + data object Youtube : NavState() + + /** Guide TV global. */ + data object ProgramGuide : NavState() + + /** Guide Replay pour une chaîne spécifique. */ + data class ReplayGuide(val stream: LiveStreamDto) : NavState() + + /** Catalogue VOD (films). */ + data class Vod(val initialCategoryId: Int? = null) : NavState() + + /** Catalogue séries. */ + data class Series(val initialCategoryId: Int? = null) : NavState() + + /** Détail d'une série (saisons + épisodes). */ + data class SeriesDetail(val series: SeriesDto, val info: SeriesInfoDto) : NavState() +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt new file mode 100644 index 0000000..36ed9c8 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt @@ -0,0 +1,343 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.navigation.NavState +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResult +import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResultType +import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchViewModel +import ma.tkgraph.xtreamtv.ui.viewmodels.SearchScope + +@Composable +fun GlobalSearchScreen( + viewModel: GlobalSearchViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val query by viewModel.query.collectAsStateWithLifecycle() + val searchScope by viewModel.scope.collectAsStateWithLifecycle() + val resultsState by viewModel.results.collectAsStateWithLifecycle() + val searchFocusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + + BackHandler { onBack() } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(160) + runCatching { searchFocusRequester.requestFocus() } + } + + Surface(modifier = Modifier.fillMaxSize(), color = DesignColors.bg1) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3))) + .padding(24.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = DesignColors.orange, + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("◀ Retour", color = DesignColors.muted) } + Spacer(Modifier.width(14.dp)) + Text( + "Recherche globale", + color = DesignColors.text, + fontSize = 30.sp, + fontWeight = FontWeight.Black + ) + } + + Spacer(Modifier.height(18.dp)) + + OutlinedTextField( + value = query, + onValueChange = viewModel::setQuery, + modifier = Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = DesignColors.orange) }, + placeholder = { Text("Rechercher une chaîne, un film ou une série…") } + ) + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SearchScope.entries.forEach { scope -> + SearchScopeButton( + text = scope.label, + selected = searchScope == scope, + onClick = { viewModel.setScope(scope) } + ) + } + Spacer(Modifier.weight(1f)) + TextButton( + onClick = viewModel::runSearch, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = DesignColors.orange, + borderWidth = 2.dp, + cornerRadius = 10.dp + ) + ) { + Text("Lancer", color = DesignColors.orange, fontWeight = FontWeight.Black) + } + } + + Spacer(Modifier.height(18.dp)) + + when (val results = resultsState) { + is UiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = DesignColors.orange) + Spacer(Modifier.height(10.dp)) + Text("Recherche en cours…", color = DesignColors.muted) + } + } + is UiState.Error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(results.message, color = MaterialTheme.colorScheme.error) + } + is UiState.Success -> { + val data = results.data + if (query.trim().length < 2) { + SearchEmptyMessage("Tape au moins 2 caractères, choisis LIVE/VOD/SÉRIES/ALL, puis lance la recherche.") + } else if (data.isEmpty()) { + SearchEmptyMessage("Aucun résultat affiché. Lance une recherche ou change de filtre.") + } else { + Text( + "${data.size} résultat(s)", + color = DesignColors.muted, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp) + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(data, key = { it.id }) { result -> + SearchResultRow( + result = result, + onClick = { + when (result.type) { + GlobalSearchResultType.Live -> { + val stream = result.liveStream + if (stream != null) { + viewModel.recordLiveUsage(stream) + onNavigate( + NavState.Player( + stream = stream, + allStreams = listOf(stream), + currentIndex = 0, + returnState = NavState.GlobalSearch + ) + ) + } + } + GlobalSearchResultType.Vod -> { + val movie = result.vod + val stream = movie?.let { viewModel.buildVodStream(it) } + if (movie != null && stream != null) { + viewModel.recordVodUsage(movie.streamId) + onNavigate( + NavState.Player( + stream = stream, + allStreams = emptyList(), + currentIndex = 0, + replayUrl = stream.directUrl, + returnState = NavState.GlobalSearch, + progressKey = "vod:${movie.streamId}" + ) + ) + } + } + GlobalSearchResultType.Series -> { + val series = result.series + if (series != null) { + viewModel.recordSeriesUsage(series.seriesId) + coroutineScope.launch { + val launch = viewModel.buildSeriesLaunch(series) ?: return@launch + onNavigate( + NavState.Player( + stream = launch.stream, + allStreams = launch.allStreams, + currentIndex = launch.currentIndex, + replayUrl = null, + returnState = NavState.GlobalSearch, + progressKey = launch.progressKey, + progressKeys = launch.progressKeys + ) + ) + } + } + } + } + } + ) + } + } + } + } + else -> SearchEmptyMessage("Recherche prête.") + } + } + } +} + +@Composable +private fun SearchScopeButton(text: String, selected: Boolean, onClick: () -> Unit) { + TextButton( + onClick = onClick, + modifier = Modifier + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) DesignColors.orange else DesignColors.stroke, + shape = RoundedCornerShape(10.dp) + ) + .tvFocusHighlight( + focusedBorderColor = DesignColors.orange, + borderWidth = 2.dp, + cornerRadius = 10.dp + ) + ) { + Text( + text, + color = if (selected) DesignColors.orange else DesignColors.muted, + fontWeight = FontWeight.Black + ) + } +} + +@Composable +private fun SearchEmptyMessage(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text, color = DesignColors.muted, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun SearchResultRow(result: GlobalSearchResult, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val accent = when (result.type) { + GlobalSearchResultType.Live -> DesignColors.orange + GlobalSearchResultType.Vod -> DesignColors.red + GlobalSearchResultType.Series -> DesignColors.green + } + + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(86.dp) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .focusable() + .border( + width = if (focused) 3.dp else 1.dp, + color = if (focused) accent else DesignColors.stroke.copy(alpha = 0.55f), + shape = RoundedCornerShape(16.dp) + ), + shape = RoundedCornerShape(16.dp), + color = if (focused) Color(0x22FFFFFF) else DesignColors.panel + ) { + Row( + modifier = Modifier.fillMaxSize().padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(width = 68.dp, height = 62.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF141414)), + contentAlignment = Alignment.Center + ) { + if (!result.imageUrl.isNullOrBlank()) { + coil3.compose.AsyncImage( + model = result.imageUrl, + contentDescription = result.title, + modifier = Modifier.fillMaxSize() + ) + } else { + Text(result.type.name.take(3).uppercase(), color = accent, fontWeight = FontWeight.Black) + } + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + result.title, + color = DesignColors.text, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + result.subtitle, + color = DesignColors.muted, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text("OK", color = accent, fontWeight = FontWeight.Black) + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..06a9843 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt @@ -0,0 +1,1145 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.app.Activity +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.HomeViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun HomeScreen( + focusStreamId: Int? = null, + viewModel: HomeViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {}, + hasAutoPlayedPin: Boolean = false, + onAutoPlayPin: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val streamsState by viewModel.streams.collectAsStateWithLifecycle() + val selectedCategoryId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + val pinnedId by viewModel.pinnedChannelId.collectAsStateWithLifecycle() + var showExitDialog by remember { mutableStateOf(false) } + var backPressedOnce by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + val categoryListState = rememberLazyListState() + val activity = (LocalContext.current as? android.app.Activity) + val cardFocusRequesters = remember { mutableStateMapOf() } + val actionBarFocusRequesters = remember { mutableStateMapOf() } + var hasRequestedInitialCardFocus by remember { mutableStateOf(false) } + var activeActionsStreamId by remember { mutableStateOf(null) } + var blockNextActionCenterUpStreamId by remember { mutableStateOf(null) } + + // ── Recherche : accessible par le bouton de recherche dans le header ── + var searchQuery by remember { mutableStateOf("") } + var isSearchFocused by remember { mutableStateOf(false) } + + LaunchedEffect(selectedCategoryId, searchQuery) { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + } + + // Auto-démarrage sur chaîne épinglée + val streamList = (streamsState as? UiState.Success)?.data + LaunchedEffect(streamList) { + if (!hasAutoPlayedPin && streamList != null && streamList.isNotEmpty()) { + val pinId = pinnedId + if (pinId != null) { + val pinnedStream = streamList.find { it.streamId == pinId } + if (pinnedStream != null) { + onAutoPlayPin() + viewModel.recordChannelUsage(pinnedStream) + onNavigate(NavState.Player(pinnedStream, streamList, streamList.indexOf(pinnedStream))) + } + } + } + } + + // Exit dialog + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false; backPressedOnce = false }, + title = { Text("Quitter iMedia TV+ ?", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { Text("Êtes-vous sûr de vouloir quitter ?", color = DesignColors.muted) }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { showExitDialog = false; activity?.finishAffinity() }) { + Text("Oui", color = DesignColors.orange) + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false; backPressedOnce = false }) { + Text("Non", color = DesignColors.muted) + } + } + ) + } + + // Settings dialog + if (showSettings) { + SettingsDialog( + onDismiss = { showSettings = false }, + onLogout = { + showSettings = false + viewModel.logout() + } + ) + } + + // ── Dialog de recherche ── + if (isSearchFocused) { + SearchInputDialog( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onDismiss = { + isSearchFocused = false + if (searchQuery.isBlank()) searchQuery = "" + }, + onSearch = { + isSearchFocused = false + } + ) + } + + // Back handler + BackHandler { + val activeId = activeActionsStreamId + if (activeId != null) { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + try { cardFocusRequesters[activeId]?.requestFocus() } catch (_: Exception) {} + } else if (isSearchFocused) { + isSearchFocused = false + if (searchQuery.isBlank()) searchQuery = "" + } else if (searchQuery.isNotEmpty()) { + searchQuery = "" + } else { + onBack() + } + } + + // ── Arrière-plan avec dégradé ── + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(DesignColors.bg3.copy(alpha = 0.3f), Color.Transparent), + radius = 1200f + ) + ) + .background( + Brush.radialGradient( + colors = listOf(DesignColors.blue.copy(alpha = 0.08f), Color.Transparent), + radius = 1400f, + center = androidx.compose.ui.geometry.Offset(2000f, 100f) + ) + ) + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ── + Box( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Brand : iMedia TV+ avec gradient orange + Text( + "iMedia TV+", + style = MaterialTheme.typography.headlineLarge.copy( + brush = Brush.linearGradient( + colors = listOf(DesignColors.orange, DesignColors.orange2) + ) + ), + fontWeight = FontWeight.Black, + letterSpacing = (-0.055).sp + ) + + // Boutons de navigation + recherche + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + // Bouton paramètres + var settingsFocused by remember { mutableStateOf(false) } + val settingsAlpha by animateFloatAsState( + targetValue = if (settingsFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { showSettings = true }, + modifier = Modifier + .size(32.dp) + .onFocusChanged { settingsFocused = it.isFocused } + .border( + width = if (settingsFocused) 2.dp else 1.dp, + color = if (settingsFocused) Color.White.copy(alpha = settingsAlpha) else DesignColors.stroke, + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = if (settingsFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("⚙", fontSize = 14.sp) + } + } + + // ── Champ de recherche (lecture seule, ouvre une dialog) ── + var searchFieldFocused by remember { mutableStateOf(false) } + Surface( + onClick = { + isSearchFocused = !isSearchFocused + if (isSearchFocused) searchQuery = "" + }, + modifier = Modifier + .widthIn(min = 120.dp, max = 200.dp) + .height(32.dp) + .onFocusChanged { searchFieldFocused = it.isFocused }, + shape = RoundedCornerShape(10.dp), + color = if (searchFieldFocused || isSearchFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF), + border = BorderStroke( + width = if (searchFieldFocused || isSearchFocused) 2.dp else 1.dp, + color = if (searchFieldFocused || isSearchFocused) DesignColors.orange.copy(alpha = 0.7f) else DesignColors.stroke + ) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text("🔍", fontSize = 12.sp) + Text( + if (searchQuery.isEmpty()) "Rechercher…" else searchQuery, + color = if (searchQuery.isEmpty()) DesignColors.muted2 else Color.White, + fontSize = 13.sp, + fontWeight = if (searchQuery.isEmpty()) FontWeight.Normal else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (searchQuery.isNotEmpty()) { + Surface( + onClick = { searchQuery = "" }, + color = Color.Transparent, + shape = RoundedCornerShape(50) + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + Text("✕", color = DesignColors.muted2, fontSize = 10.sp) + } + } + } else if (isSearchFocused) { + Text("⌨", fontSize = 10.sp, color = DesignColors.orange) + } + } + } + } + } + } + + // ── Catégories ── + when (val cats = categoriesState) { + is UiState.Success -> { + val catList = cats.data + LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) { + if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0) + } + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 2.dp), + state = categoryListState, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + item { + DesignCategoryChip( + label = "All", + isSelected = selectedCategoryId == null, + onClick = { viewModel.selectCategory(null) } + ) + } + item { + DesignCategoryChip( + label = "★ Favorites", + isSelected = selectedCategoryId == -1, + onClick = { viewModel.selectCategory(-1) } + ) + } + items(catList) { cat -> + val id = cat.categoryId.toIntOrNull() + DesignCategoryChip( + label = cat.categoryName, + isSelected = id == selectedCategoryId && selectedCategoryId != -1, + onClick = { viewModel.selectCategory(id) } + ) + } + } + } + else -> {} + } + + // ── Liste des chaînes ── + when (val strs = streamsState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text("Chargement des chaînes…", color = DesignColors.muted) + } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(strs.message, color = DesignColors.red) + } + is UiState.Success -> { + val baseFiltered = if (selectedCategoryId == -1) strs.data.filter { it.streamId in favoriteIds } + else strs.data + val filtered = if (searchQuery.isBlank()) baseFiltered + else baseFiltered.filter { it.name.contains(searchQuery, ignoreCase = true) } + + LaunchedEffect(focusStreamId, selectedCategoryId, searchQuery, filtered.size) { + val targetId = focusStreamId + if (targetId != null) { + val index = filtered.indexOfFirst { it.streamId == targetId } + if (index >= 0) { + listState.animateScrollToItem(index) + kotlinx.coroutines.delay(120) + cardFocusRequesters[targetId]?.requestFocus() + } + } else if (!hasRequestedInitialCardFocus && filtered.isNotEmpty()) { + hasRequestedInitialCardFocus = true + kotlinx.coroutines.delay(180) + cardFocusRequesters[filtered.first().streamId]?.requestFocus() + } + } + + fun focusStreamAt(index: Int) { + val target = filtered.getOrNull(index) ?: return + try { cardFocusRequesters[target.streamId]?.requestFocus() } catch (_: Exception) {} + } + + fun scrollToStreamIndex(index: Int) { + if (filtered.isEmpty()) return + val targetIndex = index.coerceIn(0, filtered.lastIndex) + coroutineScope.launch { + listState.animateScrollToItem(targetIndex) + kotlinx.coroutines.delay(80) + focusStreamAt(targetIndex) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .tvFastScrollKeys( + enabled = filtered.isNotEmpty() && activeActionsStreamId == null, + onJumpToTop = { scrollToStreamIndex(0) }, + onJumpToBottom = { scrollToStreamIndex(filtered.lastIndex) }, + onPageUp = { scrollToStreamIndex(listState.firstVisibleItemIndex - 15) }, + onPageDown = { scrollToStreamIndex(listState.firstVisibleItemIndex + 15) } + ), + state = listState, + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(filtered, key = { it.streamId }) { stream -> + val streamCardFocusRequester = remember(stream.streamId) { + cardFocusRequesters.getOrPut(stream.streamId) { FocusRequester() } + } + val replayFocusRequester = remember(stream.streamId) { + actionBarFocusRequesters.getOrPut(stream.streamId) { FocusRequester() } + } + DesignChannelCard( + modifier = Modifier.focusRequester(streamCardFocusRequester), + name = stream.name, + logoUrl = stream.streamIcon, + isFavorite = stream.streamId in favoriteIds, + isPinned = stream.streamId == pinnedId, + actionsEnabled = activeActionsStreamId == stream.streamId, + actionBarFocusRequester = replayFocusRequester, + consumeFirstActionCenterUp = blockNextActionCenterUpStreamId == stream.streamId, + onFirstActionCenterUpConsumed = { + if (blockNextActionCenterUpStreamId == stream.streamId) { + blockNextActionCenterUpStreamId = null + } + }, + onEnterActions = { + activeActionsStreamId = stream.streamId + blockNextActionCenterUpStreamId = stream.streamId + }, + onExitActions = { + activeActionsStreamId = null + blockNextActionCenterUpStreamId = null + try { streamCardFocusRequester.requestFocus() } catch (_: Exception) {} + }, + onPlay = { + viewModel.recordChannelUsage(stream) + onNavigate(NavState.Player(stream, strs.data, strs.data.indexOf(stream), returnState = NavState.Home(stream.streamId))) + }, + onToggleFav = { viewModel.toggleFavorite(stream.streamId) }, + onTogglePin = { viewModel.togglePin(stream.streamId) }, + onReplay = { onNavigate(NavState.ReplayGuide(stream)) } + ) + } + } + } + else -> {} + } + } + } +} + +// ─── NAV CHIP ─── +@Composable +private fun DesignNavChip(label: String, color: Color, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val borderAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(150) + ) + + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) color.copy(alpha = borderAlpha) else DesignColors.stroke, + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = if (isFocused) color.copy(alpha = 0.25f) else Color(0x0DFFFFFF) + ) { + Row( + modifier = Modifier.height(32.dp).padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(label, color = if (isFocused) Color.White else color, fontWeight = FontWeight.Black, fontSize = 14.sp) + } + } +} + +// ─── CATEGORY CHIP ─── +@Composable +private fun DesignCategoryChip(label: String, isSelected: Boolean, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + Surface( + onClick = onClick, + modifier = Modifier.onFocusChanged { isFocused = it.isFocused }, + shape = RoundedCornerShape(999.dp), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .height(28.dp) + .then( + if (isSelected) Modifier.background( + Brush.linearGradient(listOf(DesignColors.orange, DesignColors.orange2)), + RoundedCornerShape(999.dp) + ) else Modifier.background( + if (isFocused) Color(0x22FFFFFF) else Color(0x0DFFFFFF), + RoundedCornerShape(999.dp) + ) + ) + .border( + when { + isSelected -> BorderStroke(1.dp, Color(0xB3FFCA46)) + isFocused -> BorderStroke(1.dp, Color.White.copy(alpha = 0.6f)) + else -> BorderStroke(1.dp, DesignColors.stroke) + }, + RoundedCornerShape(999.dp) + ) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + label, + color = if (isSelected) Color.White else DesignColors.text, + fontWeight = if (isSelected) FontWeight.Black else FontWeight.ExtraBold, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +// ─── CHANNEL CARD ─── +@Composable +private fun DesignChannelCard( + modifier: Modifier = Modifier, + name: String, + logoUrl: String?, + isFavorite: Boolean, + isPinned: Boolean, + actionsEnabled: Boolean, + actionBarFocusRequester: FocusRequester, + consumeFirstActionCenterUp: Boolean, + onFirstActionCenterUpConsumed: () -> Unit, + onEnterActions: () -> Unit, + onExitActions: () -> Unit, + onPlay: () -> Unit, + onToggleFav: () -> Unit, + onTogglePin: () -> Unit, + onReplay: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + var centerIsDown by remember { mutableStateOf(false) } + var centerLongPressTriggered by remember { mutableStateOf(false) } + var actionBarFocused by remember { mutableStateOf(false) } + var selectedActionIndex by remember { mutableStateOf(0) } + + LaunchedEffect(actionsEnabled) { + if (!actionsEnabled) { + selectedActionIndex = 0 + actionBarFocused = false + } + } + + val cardBg = Brush.linearGradient( + colors = listOf( + Color(0x7A6B2673), + Color(0xD6141C52), + Color(0xC7081744) + ), + start = androidx.compose.ui.geometry.Offset.Zero, + end = androidx.compose.ui.geometry.Offset(5000f, 5000f) + ) + + fun isCenterKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + val keyCode = event.nativeKeyEvent.keyCode + return keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == android.view.KeyEvent.KEYCODE_ENTER || + event.key == Key.Enter + } + + fun shouldBlockActionCenterEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + return consumeFirstActionCenterUp && + isCenterKey(event) && + (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) + } + + fun requestFocusSafely(requester: FocusRequester) { + try { requester.requestFocus() } catch (_: Exception) {} + } + + fun handleActionDirection( + event: androidx.compose.ui.input.key.KeyEvent, + onLeft: (() -> Unit)? = null, + onRight: (() -> Unit)? = null + ): Boolean { + if (event.type != KeyEventType.KeyDown && event.type != KeyEventType.KeyUp) return false + + return when (event.key) { + Key.DirectionLeft -> { + if (event.type == KeyEventType.KeyDown) onLeft?.invoke() + true + } + Key.DirectionRight -> { + if (event.type == KeyEventType.KeyDown) onRight?.invoke() + true + } + Key.DirectionUp, Key.DirectionDown -> { + if (event.type == KeyEventType.KeyDown) onExitActions() + true + } + Key.Back, Key.Escape -> { + if (event.type == KeyEventType.KeyDown) onExitActions() + true + } + else -> false + } + } + + val cardBorderColor = when { + isFocused && actionsEnabled -> Color.White + isFocused -> DesignColors.orange + actionsEnabled -> Color.White.copy(alpha = 0.78f) + isPinned -> DesignColors.orange.copy(alpha = 0.85f) + isFavorite -> Color(0xFFFFD700).copy(alpha = 0.5f) + else -> Color(0x297896FF) + } + val cardBorderWidth = if (isFocused || actionsEnabled || isPinned) 3.dp else 1.dp + + Surface( + modifier = modifier + .fillMaxWidth() + // IMPORTANT : onFocusChanged doit être AVANT focusable(). + // Sinon le contour de focus peut ne plus recevoir l'état de focus sur Android TV. + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .focusable() + .onPreviewKeyEvent { event -> + // Quand le bloc d’actions a déjà le focus, on laisse ses propres + // touches gauche/droite gérer Replay → Épingler → Favori. + // Sinon le parent consomme toujours Droite et bloque la navigation interne. + if (actionsEnabled && !actionBarFocused && event.key == Key.DirectionRight) { + if (event.type == KeyEventType.KeyDown) { + selectedActionIndex = 0 + requestFocusSafely(actionBarFocusRequester) + } + return@onPreviewKeyEvent true + } + + // Si le bloc d'actions a le focus, le parent ne doit surtout pas + // traiter OK/Enter. Sinon un clic sur Replay déclenche onPlay(), + // donc la chaîne Live, pendant que le bouton Replay affiche son + // plus beau mensonge visuel. + if (actionsEnabled && actionBarFocused && isCenterKey(event)) { + return@onPreviewKeyEvent false + } + + if (!isCenterKey(event)) return@onPreviewKeyEvent false + + when (event.type) { + KeyEventType.KeyDown -> { + if (!centerIsDown) { + centerIsDown = true + centerLongPressTriggered = false + } + + val isLongPress = event.nativeKeyEvent.repeatCount > 0 || event.nativeKeyEvent.isLongPress + if (isLongPress && !centerLongPressTriggered) { + centerLongPressTriggered = true + onEnterActions() + } + true + } + KeyEventType.KeyUp -> { + val wasLongPress = centerLongPressTriggered + centerIsDown = false + centerLongPressTriggered = false + if (wasLongPress) onFirstActionCenterUpConsumed() else onPlay() + true + } + else -> false + } + } + .background(cardBg, RoundedCornerShape(14.dp)) + .border( + width = cardBorderWidth, + color = cardBorderColor, + shape = RoundedCornerShape(14.dp) + ), + shape = RoundedCornerShape(14.dp), + color = Color.Transparent + ) { + Row( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Logo + Surface( + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(9.dp), + color = Color.White, + shadowElevation = 4.dp + ) { + Box(contentAlignment = Alignment.Center) { + if (logoUrl != null) { + coil3.compose.AsyncImage( + model = logoUrl, + contentDescription = name, + modifier = Modifier.size(36.dp).clip(RoundedCornerShape(9.dp)) + ) + } else { + Text( + name.take(2).uppercase(), + color = DesignColors.bg1, + fontWeight = FontWeight.Black, + fontSize = 14.sp + ) + } + } + } + + Spacer(Modifier.width(10.dp)) + + // Infos + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = DesignColors.liveRed + ) { + Row( + modifier = Modifier.height(18.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(4.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFFFFD66B)) + ) + Text("LIVE", color = Color.White, fontWeight = FontWeight.Black, fontSize = 10.sp) + } + } + + Text( + name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Black, + color = DesignColors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color(0x3E0D5BCE) + ) { + Box(Modifier.height(16.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.Center) { + Text("IPTV", color = DesignColors.blue.copy(alpha = 0.8f), fontWeight = FontWeight.Black, fontSize = 10.sp) + } + } + Box(Modifier.width(1.dp).height(16.dp).background(DesignColors.stroke)) + Text( + if (actionsEnabled) "Actions : Replay / Pin / Favori" else name, + color = if (actionsEnabled) Color.White.copy(alpha = 0.85f) else DesignColors.muted, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(10.dp)) + + // Actions : un seul bloc focusable, avec sélection interne. + // C'est volontaire : trois petits boutons focusables dans une ligne TV finissent souvent + // par envoyer le focus dans les Paramètres, parce que Compose aime le théâtre absurde. + val actionLabels = listOf("Replay", if (isPinned) "📌" else "📍", if (isFavorite) "★" else "☆") + val actionColors = listOf(DesignColors.blue, DesignColors.orange, Color(0xFFFFD700)) + + fun runSelectedAction() { + when (selectedActionIndex) { + 0 -> onReplay() + 1 -> onTogglePin() + 2 -> onToggleFav() + } + } + + Row( + modifier = if (actionsEnabled) { + Modifier + .focusRequester(actionBarFocusRequester) + .onFocusChanged { actionBarFocused = it.isFocused || it.hasFocus } + .focusable() + .onPreviewKeyEvent { event -> + if (shouldBlockActionCenterEvent(event)) { + if (event.type == KeyEventType.KeyUp) onFirstActionCenterUpConsumed() + return@onPreviewKeyEvent true + } + + if (event.type != KeyEventType.KeyDown && event.type != KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + + when (event.key) { + Key.DirectionRight -> { + if (event.type == KeyEventType.KeyDown) { + selectedActionIndex = (selectedActionIndex + 1).coerceAtMost(2) + } + true + } + Key.DirectionLeft -> { + if (event.type == KeyEventType.KeyDown) { + if (selectedActionIndex > 0) { + selectedActionIndex -= 1 + } else { + onExitActions() + } + } + true + } + Key.DirectionUp, Key.DirectionDown -> { + if (event.type == KeyEventType.KeyDown) onExitActions() + true + } + Key.Back, Key.Escape -> { + if (event.type == KeyEventType.KeyDown) onExitActions() + true + } + Key.Enter -> { + if (event.type == KeyEventType.KeyUp) runSelectedAction() + true + } + else -> { + if (isCenterKey(event)) { + if (event.type == KeyEventType.KeyUp) runSelectedAction() + true + } else { + false + } + } + } + } + } else { + Modifier + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + actionLabels.forEachIndexed { index, label -> + val selected = actionsEnabled && actionBarFocused && selectedActionIndex == index + val visible = actionsEnabled || index == 0 || isPinned || isFavorite + val alpha = when { + actionsEnabled -> 1f + visible -> 0.55f + else -> 0.35f + } + val bgColor = when { + actionsEnabled && index == 0 -> DesignColors.blue + actionsEnabled && selected -> actionColors[index].copy(alpha = 0.22f) + index == 0 -> DesignColors.blue.copy(alpha = 0.45f) + else -> Color.Transparent + } + val borderColor = when { + selected -> actionColors[index] + actionsEnabled -> Color.White.copy(alpha = 0.28f) + index == 0 -> Color(0x5560C2FF) + else -> Color.Transparent + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = bgColor, + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = borderColor + ) + ) { + Box( + modifier = if (index == 0) { + Modifier.height(28.dp).padding(horizontal = 10.dp) + } else { + Modifier.size(28.dp) + }, + contentAlignment = Alignment.Center + ) { + Text( + label, + color = when (index) { + 0 -> Color.White.copy(alpha = alpha) + 1 -> Color.White.copy(alpha = alpha) + else -> if (isFavorite || selected) Color(0xFFFFD700).copy(alpha = alpha) else DesignColors.muted.copy(alpha = alpha) + }, + fontWeight = FontWeight.Black, + fontSize = if (index == 0) 13.sp else 16.sp + ) + } + } + } + } + } + } +} + +// ─── SETTINGS DIALOG ─── +@Composable +private fun SettingsDialog(onDismiss: () -> Unit, onLogout: () -> Unit) { + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Paramètres", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { + Column { + if (config != null) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("Source : ", color = DesignColors.muted, fontSize = 14.sp) + Text( + if (config.sourceType == SourceType.XTREAM) "Xtream" else "M3U", + color = DesignColors.orange, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + if (config.sourceType == SourceType.XTREAM) { + InfoRow("Serveur", config.serverUrl) + InfoRow("Utilisateur", config.username) + } else { + InfoRow("URL M3U", config.m3uUrl) + } + } + Spacer(Modifier.height(12.dp)) + Text( + "Appuyez sur Déconnecter pour changer de source.", + color = DesignColors.muted2, + fontSize = 12.sp + ) + } + }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { onLogout(); onDismiss() }) { + Text("Déconnecter", color = DesignColors.red) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Fermer", color = DesignColors.muted) } + } + ) +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("$label : ", color = DesignColors.muted, fontSize = 13.sp) + Text(value, color = DesignColors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } +} + +// ─── SEARCH INPUT DIALOG ─── +@Composable +private fun SearchInputDialog( + query: String, + onQueryChange: (String) -> Unit, + onDismiss: () -> Unit, + onSearch: () -> Unit +) { + var localQuery by remember(query) { mutableStateOf(query) } + val textFocus = remember { FocusRequester() } + val okFocus = remember { FocusRequester() } + var textHasFocus by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + try { textFocus.requestFocus() } catch (_: Exception) {} + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xCC000000)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { } + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .widthIn(max = 500.dp) + .background(DesignColors.bg2, RoundedCornerShape(16.dp)) + .border(1.dp, DesignColors.stroke, RoundedCornerShape(16.dp)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Titre + Text( + "🔍 Rechercher une chaîne", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ) + Spacer(Modifier.height(16.dp)) + + // Champ de saisie + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0x22FFFFFF), + border = BorderStroke( + 1.dp, + if (localQuery.isNotEmpty()) DesignColors.orange.copy(alpha = 0.6f) else DesignColors.stroke + ) + ) { + BasicTextField( + value = localQuery, + onValueChange = { + localQuery = it + onQueryChange(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .focusRequester(textFocus) + .onFocusChanged { textHasFocus = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key == Key.DirectionDown) { + textHasFocus = false + try { okFocus.requestFocus() } catch (_: Exception) {} + true + } else false + }, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium + ), + decorationBox = { innerTextField -> + Box { + if (localQuery.isEmpty()) { + Text( + "Tapez le nom de la chaîne…", + color = DesignColors.muted2, fontSize = 16.sp + ) + } + innerTextField() + } + } + ) + } + + Spacer(Modifier.height(20.dp)) + + // Indication du nombre de résultats + if (localQuery.isNotEmpty()) { + Text( + "Filtrage en cours…", + color = DesignColors.muted2, fontSize = 13.sp + ) + Spacer(Modifier.height(12.dp)) + } + + // Boutons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Annuler + var cancelFocused by remember { mutableStateOf(false) } + val cancelAlpha by animateFloatAsState( + targetValue = if (cancelFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { + onQueryChange(localQuery) + onDismiss() + }, + modifier = Modifier + .onFocusChanged { cancelFocused = it.isFocused } + .border( + width = if (cancelFocused) 2.dp else 0.dp, + color = Color.White.copy(alpha = cancelAlpha), + shape = RoundedCornerShape(8.dp) + ), + shape = RoundedCornerShape(8.dp), + color = Color.Transparent + ) { + Box(Modifier.height(36.dp).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) { + Text( + "Annuler", + color = if (cancelFocused) Color.White else DesignColors.muted, + fontWeight = if (cancelFocused) FontWeight.Bold else FontWeight.Medium, + fontSize = 14.sp + ) + } + } + + Spacer(Modifier.width(8.dp)) + + // OK + var okFocused by remember { mutableStateOf(false) } + val okAlpha by animateFloatAsState( + targetValue = if (okFocused) 1f else 0f, + animationSpec = tween(150) + ) + Surface( + onClick = { + onQueryChange(localQuery) + onSearch() + }, + modifier = Modifier + .focusRequester(okFocus) + .onFocusChanged { okFocused = it.isFocused } + .border( + width = if (okFocused) 2.dp else 0.dp, + color = DesignColors.orange.copy(alpha = okAlpha), + shape = RoundedCornerShape(8.dp) + ) + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key == Key.DirectionUp) { + okFocused = false + textHasFocus = true + try { textFocus.requestFocus() } catch (_: Exception) {} + true + } else false + }, + shape = RoundedCornerShape(8.dp), + color = DesignColors.orange + ) { + Box(Modifier.height(36.dp).padding(horizontal = 24.dp), contentAlignment = Alignment.Center) { + Text( + "OK", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + } + } + + // Indice navigation + Spacer(Modifier.height(8.dp)) + Text( + "⬇ pour OK • BACK pour fermer", + color = DesignColors.muted2, + fontSize = 11.sp + ) + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..37f0db4 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt @@ -0,0 +1,599 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.text.InputType +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.content.Context +import android.widget.EditText +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel + + +private enum class LoginInputField { + SERVER_URL, + USERNAME, + PASSWORD, + M3U_URL +} + +/** + * Écran de connexion / configuration. + * Permet de choisir entre serveur Xtream et fichier M3U. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + viewModel: LoginViewModel = viewModel(), + onLoginSuccess: () -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val serverUrl = viewModel.serverUrl + val username = viewModel.username + val password = viewModel.password + val m3uUrl = viewModel.m3uUrl + val sourceType = viewModel.sourceType + var showModeMenu by remember { mutableStateOf(false) } + var editingField by remember { mutableStateOf(null) } + val serverFocusRequester = remember { FocusRequester() } + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + val m3uFocusRequester = remember { FocusRequester() } + val submitFocusRequester = remember { FocusRequester() } + + LaunchedEffect(sourceType) { + runCatching { + if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus() + else m3uFocusRequester.requestFocus() + } + } + + LaunchedEffect(uiState) { + if (uiState is UiState.Success) { + onLoginSuccess() + } + } + + // Vérifier si des credentials sont déjà stockés + val savedConfig = remember { + XtreamApplication.instance.appContainer.secureStorage.getConfig() + } + + when (editingField) { + LoginInputField.SERVER_URL -> NativeLoginInputDialog( + title = "URL du serveur", + value = serverUrl, + hint = "http://serveur:8080", + onDismiss = { editingField = null }, + onConfirm = { + viewModel.updateServerUrl(it) + editingField = null + } + ) + LoginInputField.USERNAME -> NativeLoginInputDialog( + title = "Identifiant", + value = username, + hint = "Identifiant", + onDismiss = { editingField = null }, + onConfirm = { + viewModel.updateUsername(it) + editingField = null + } + ) + LoginInputField.PASSWORD -> NativeLoginInputDialog( + title = "Mot de passe", + value = password, + hint = "Mot de passe", + password = true, + onDismiss = { editingField = null }, + onConfirm = { + viewModel.updatePassword(it) + editingField = null + } + ) + LoginInputField.M3U_URL -> NativeLoginInputDialog( + title = "URL du fichier M3U", + value = m3uUrl, + hint = "https://exemple.com/playlist.m3u", + onDismiss = { editingField = null }, + onConfirm = { + viewModel.updateM3uUrl(it) + editingField = null + } + ) + null -> Unit + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = DesignColors.bg1 + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + .verticalScroll(rememberScrollState()) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(Modifier.height(32.dp)) + + // Titre avec gradient orange + Text( + text = "iMedia TV+", + style = MaterialTheme.typography.headlineLarge.copy( + brush = Brush.linearGradient( + colors = listOf(DesignColors.orange, DesignColors.orange2) + ) + ), + fontWeight = FontWeight.Black, + letterSpacing = (-0.055).sp + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Configuration", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF888888) + ) + + Spacer(Modifier.height(24.dp)) + + // ─── Sélecteur de mode ─── + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ModeButton( + label = "Xtream", + isSelected = sourceType == SourceType.XTREAM, + onClick = { viewModel.switchSourceType(SourceType.XTREAM) } + ) + ModeButton( + label = "M3U", + isSelected = sourceType == SourceType.M3U, + onClick = { viewModel.switchSourceType(SourceType.M3U) } + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // ─── Champs selon le mode ─── + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (sourceType == SourceType.XTREAM) { + // ── Mode Xtream ── + Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(4.dp)) + Text( + "Flèches haut/bas pour changer de champ. OK pour saisir.", + color = Color(0xFF888888), + fontSize = 11.sp + ) + Spacer(Modifier.height(12.dp)) + + TvEditableLoginField( + label = "URL du serveur", + value = serverUrl, + hint = "http://serveur:8080", + enabled = uiState !is UiState.Loading, + onClick = { editingField = LoginInputField.SERVER_URL }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(serverFocusRequester) + .moveFocusOnDpad( + down = { usernameFocusRequester.requestFocus() } + ) + ) + Spacer(Modifier.height(8.dp)) + + TvEditableLoginField( + label = "Identifiant", + value = username, + hint = "Identifiant", + enabled = uiState !is UiState.Loading, + onClick = { editingField = LoginInputField.USERNAME }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(usernameFocusRequester) + .moveFocusOnDpad( + up = { serverFocusRequester.requestFocus() }, + down = { passwordFocusRequester.requestFocus() } + ) + ) + Spacer(Modifier.height(8.dp)) + + TvEditableLoginField( + label = "Mot de passe", + value = password, + hint = "Mot de passe", + password = true, + enabled = uiState !is UiState.Loading, + onClick = { editingField = LoginInputField.PASSWORD }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(passwordFocusRequester) + .moveFocusOnDpad( + up = { usernameFocusRequester.requestFocus() }, + down = { submitFocusRequester.requestFocus() }, + right = { submitFocusRequester.requestFocus() } + ) + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.login() }, + enabled = uiState !is UiState.Loading + && serverUrl.isNotBlank() + && username.isNotBlank() + && password.isNotBlank(), + modifier = Modifier + .width(300.dp) + .height(48.dp) + .align(Alignment.CenterHorizontally) + .focusRequester(submitFocusRequester) + .moveFocusOnDpad( + up = { passwordFocusRequester.requestFocus() }, + left = { passwordFocusRequester.requestFocus() } + ), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914)) + ) { + if (uiState is UiState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } + } else { + // ── Mode M3U ── + Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.height(4.dp)) + Text( + "Flèche bas pour descendre. OK pour saisir.", + color = Color(0xFF888888), + fontSize = 11.sp + ) + Spacer(Modifier.height(12.dp)) + + TvEditableLoginField( + label = "URL du fichier M3U", + value = m3uUrl, + hint = "https://exemple.com/playlist.m3u", + onClick = { editingField = LoginInputField.M3U_URL }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(m3uFocusRequester) + .moveFocusOnDpad( + down = { submitFocusRequester.requestFocus() } + ) + ) + Spacer(Modifier.height(4.dp)) + Text( + "Entrez l'URL d'un fichier M3U distant", + color = Color(0xFF666666), + fontSize = 11.sp + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.saveM3uConfig() }, + enabled = m3uUrl.isNotBlank(), + modifier = Modifier + .width(300.dp) + .height(48.dp) + .align(Alignment.CenterHorizontally) + .focusRequester(submitFocusRequester) + .moveFocusOnDpad( + up = { m3uFocusRequester.requestFocus() } + ), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { + Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } + } + } + + // Message d'erreur + if (uiState is UiState.Error) { + Spacer(Modifier.height(16.dp)) + Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) { + Text( + text = (uiState as UiState.Error).message, + modifier = Modifier.padding(16.dp), + color = Color(0xFFFF5252) + ) + } + } + + // Si déjà connecté, afficher les infos + if (savedConfig != null) { + Spacer(Modifier.height(16.dp)) + Surface( + shape = RoundedCornerShape(10.dp), + color = Color(0xFF1A3A1A), + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + TextButton(onClick = { viewModel.logout() }) { + Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} + + +@Composable +private fun TvEditableLoginField( + label: String, + value: String, + hint: String, + password: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + var isFocused by remember { mutableStateOf(false) } + val visibleValue = when { + value.isBlank() -> hint + password -> "●".repeat(value.length.coerceAtLeast(1)) + else -> value + } + + Column(modifier = modifier) { + Text( + label, + color = if (isFocused) Color(0xFFE50914) else Color(0xFFAAAAAA), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 2.dp, bottom = 4.dp) + ) + + Surface( + onClick = { + if (enabled) onClick() + }, + shape = RoundedCornerShape(8.dp), + color = Color(0xFF262626), + border = BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) Color(0xFFE50914) else Color(0xFF444444) + ), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .focusable(enabled) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + visibleValue, + color = if (value.isBlank()) Color(0xFF777777) else Color.White, + fontSize = 16.sp, + maxLines = 1 + ) + Spacer(Modifier.weight(1f)) + Text( + "OK", + color = if (isFocused) Color(0xFFE50914) else Color(0xFF777777), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +private fun NativeLoginInputDialog( + title: String, + value: String, + hint: String, + password: Boolean = false, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + val context = LocalContext.current + + DisposableEffect(title, value, hint, password) { + var handled = false + + val editText = EditText(context).apply { + setSingleLine(true) + setText(value) + setHint(hint) + setSelection(text?.length ?: 0) + textSize = 18f + inputType = if (password) { + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } else { + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + } + setPadding(24, 12, 24, 12) + } + + val dialog = android.app.AlertDialog.Builder(context) + .setTitle(title) + .setView(editText) + .setPositiveButton("OK") { _, _ -> + handled = true + onConfirm(editText.text?.toString().orEmpty()) + } + .setNegativeButton("Annuler") { _, _ -> + handled = true + onDismiss() + } + .create() + + dialog.setOnDismissListener { + if (!handled) onDismiss() + } + + dialog.setOnShowListener { + editText.requestFocus() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + editText.post { + editText.requestFocus() + editText.setSelection(editText.text?.length ?: 0) + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + } + + dialog.show() + + onDispose { + if (dialog.isShowing) { + dialog.dismiss() + } + } + } +} + + +@Composable +private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(8.dp), + color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333), + modifier = Modifier.weight(1f) + ) { + Text( + label, + modifier = Modifier.padding(vertical = 10.dp), + color = if (isSelected) Color.White else Color(0xFFAAAAAA), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } +} + +private fun Modifier.moveFocusOnDpad( + up: (() -> Unit)? = null, + down: (() -> Unit)? = null, + left: (() -> Unit)? = null, + right: (() -> Unit)? = null +): Modifier = onPreviewKeyEvent { event -> + // Important Android TV / Mi Box: + // consume DPAD on KeyDown. If KeyDown is left to the system and KeyUp + // also moves focus, one press can jump over the next field. Human input, + // naturally, needed traffic control. + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + when (event.key) { + Key.DirectionUp -> { + if (up != null) { + up.invoke() + true + } else { + false + } + } + Key.DirectionDown -> { + if (down != null) { + down.invoke() + true + } else { + false + } + } + Key.DirectionLeft -> { + if (left != null) { + left.invoke() + true + } else { + false + } + } + Key.DirectionRight -> { + if (right != null) { + right.invoke() + true + } else { + false + } + } + else -> false + } +} + +@Composable +private fun fieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFFE50914), + unfocusedBorderColor = Color(0xFF333333), + focusedLabelColor = Color(0xFFE50914), + unfocusedLabelColor = Color(0xFF888888), + cursorColor = Color.White, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White +) diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt new file mode 100644 index 0000000..f6d8a34 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt @@ -0,0 +1,744 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.delay +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.navigation.NavState +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.ContinueWatchingItem +import ma.tkgraph.xtreamtv.ui.viewmodels.MainMenuViewModel +import ma.tkgraph.xtreamtv.ui.viewmodels.RecentLiveItem + +@Composable +fun MainMenuScreen( + viewModel: MainMenuViewModel = viewModel(), + onOpenTv: () -> Unit, + onOpenVod: () -> Unit, + onOpenSeries: () -> Unit, + onOpenSearch: () -> Unit, + onOpenYoutube: () -> Unit, + onNavigate: (NavState) -> Unit = {} +) { + val activity = LocalContext.current as? Activity + var showSettings by remember { mutableStateOf(false) } + var showExitDialog by remember { mutableStateOf(false) } + val tvFocusRequester = remember { FocusRequester() } + val mainScrollState = rememberScrollState() + val continueItemsState by viewModel.continueItems.collectAsStateWithLifecycle() + val recentLiveItemsState by viewModel.recentLiveItems.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + delay(180) + try { tvFocusRequester.requestFocus() } catch (_: Exception) {} + viewModel.refreshRecentLive() + viewModel.refreshContinueWatching() + } + + BackHandler { showExitDialog = true } + + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text("Quitter iMedia TV+ ?", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { Text("Êtes-vous sûr de vouloir quitter l'application ?", color = DesignColors.muted) }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { showExitDialog = false; activity?.finishAffinity() }) { + Text("Oui", color = DesignColors.orange) + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text("Non", color = DesignColors.muted) + } + } + ) + } + + if (showSettings) { + MainMenuSettingsDialog( + onDismiss = { showSettings = false }, + onLogout = { + showSettings = false + XtreamApplication.instance.appContainer.m3uRepository.invalidateCache() + XtreamApplication.instance.appContainer.authRepository.logout() + activity?.recreate() + } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(DesignColors.bg3.copy(alpha = 0.45f), Color.Transparent), + radius = 1200f + ) + ) + .background( + Brush.radialGradient( + colors = listOf(DesignColors.blue.copy(alpha = 0.12f), Color.Transparent), + radius = 1400f, + center = androidx.compose.ui.geometry.Offset(1900f, 120f) + ) + ) + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + ) { + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 20.dp, end = 28.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MainMenuSettingsButton(onClick = { showSettings = true }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(mainScrollState) + .padding(horizontal = 44.dp, vertical = 34.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "iMedia TV+", + style = MaterialTheme.typography.displaySmall.copy( + brush = Brush.linearGradient( + colors = listOf(DesignColors.orange, DesignColors.orange2) + ) + ), + fontWeight = FontWeight.Black, + letterSpacing = (-0.08).sp + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Choisissez votre espace", + color = DesignColors.muted, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + + Spacer(Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + MainMenuTile( + modifier = Modifier.focusRequester(tvFocusRequester), + icon = "TV", + title = "TV", + subtitle = "Chaînes en direct", + accent = DesignColors.orange, + onClick = onOpenTv + ) + + Spacer(Modifier.width(14.dp)) + + MainMenuTile( + icon = "VOD", + title = "VOD", + subtitle = "Films à la demande", + accent = DesignColors.red, + onClick = onOpenVod + ) + + Spacer(Modifier.width(14.dp)) + + MainMenuTile( + icon = "SER", + title = "SÉRIES", + subtitle = "Saisons et épisodes", + accent = DesignColors.green, + onClick = onOpenSeries + ) + + Spacer(Modifier.width(14.dp)) + + MainMenuTile( + icon = "SRC", + title = "RECHERCHE", + subtitle = "LIVE, VOD et séries", + accent = DesignColors.blue, + onClick = onOpenSearch + ) + + Spacer(Modifier.width(14.dp)) + + MainMenuTile( + icon = "YT", + title = "YOUTUBE", + subtitle = "Vidéos enregistrées", + accent = DesignColors.red, + onClick = onOpenYoutube + ) + } + + Spacer(Modifier.height(26.dp)) + + RecentLiveSection( + itemsState = recentLiveItemsState, + onSectionFocused = {}, + onPlay = { item -> + viewModel.recordRecentLive(item.stream) + onNavigate( + NavState.Player( + stream = item.stream, + allStreams = listOf(item.stream), + currentIndex = 0, + replayUrl = null, + returnState = NavState.MainMenu + ) + ) + } + ) + + Spacer(Modifier.height(18.dp)) + + ContinueWatchingSection( + itemsState = continueItemsState, + onSectionFocused = {}, + onPlay = { item -> + onNavigate( + NavState.Player( + stream = item.stream, + allStreams = item.allStreams, + currentIndex = item.currentIndex, + replayUrl = null, + returnState = NavState.MainMenu, + progressKey = item.progressKey, + progressKeys = item.progressKeys + ) + ) + } + ) + + Spacer(Modifier.height(18.dp)) + + Text( + "OK pour ouvrir • BACK pour quitter", + color = DesignColors.muted2, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + + Spacer(Modifier.height(42.dp)) + } + } +} + +@Composable +private fun RecentLiveSection( + itemsState: UiState>, + onSectionFocused: () -> Unit = {}, + onPlay: (RecentLiveItem) -> Unit +) { + when (itemsState) { + is UiState.Success -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + "Dernières chaînes LIVE", + color = DesignColors.text, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(start = 10.dp, bottom = 10.dp) + ) + + if (itemsState.data.isEmpty()) { + Surface( + modifier = Modifier + .width(420.dp) + .height(78.dp), + shape = RoundedCornerShape(18.dp), + color = DesignColors.panel, + border = BorderStroke(1.dp, DesignColors.stroke.copy(alpha = 0.55f)) + ) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = 18.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + "Aucune chaîne récente. Lancez une chaîne LIVE, puis revenez ici.", + color = DesignColors.muted, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + return + } + + LazyRow( + modifier = Modifier.fillMaxWidth().height(112.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(itemsState.data, key = { it.id }) { item -> + RecentLiveCard( + item = item, + onFocused = onSectionFocused, + onPlay = { onPlay(item) } + ) + } + } + } + } + else -> return + } +} + +@Composable +private fun RecentLiveCard( + item: RecentLiveItem, + onFocused: () -> Unit = {}, + onPlay: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + + Surface( + onClick = onPlay, + modifier = Modifier + .width(260.dp) + .height(104.dp) + .onFocusChanged { + val focused = it.isFocused || it.hasFocus + if (focused && !isFocused) onFocused() + isFocused = focused + } + .focusable() + .border( + width = if (isFocused) 3.dp else 1.dp, + color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f), + shape = RoundedCornerShape(18.dp) + ), + shape = RoundedCornerShape(18.dp), + color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel + ) { + Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(width = 76.dp, height = 82.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF151515)), + contentAlignment = Alignment.Center + ) { + if (!item.imageUrl.isNullOrBlank()) { + coil3.compose.AsyncImage( + model = item.imageUrl, + contentDescription = item.title, + modifier = Modifier.fillMaxSize() + ) + } else { + Text("TV", color = DesignColors.orange, fontWeight = FontWeight.Black) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + item.title, + color = DesignColors.text, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(6.dp)) + Text( + item.subtitle, + color = DesignColors.muted, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun ContinueWatchingSection( + itemsState: UiState>, + onSectionFocused: () -> Unit = {}, + onPlay: (ContinueWatchingItem) -> Unit +) { + when (itemsState) { + UiState.Loading -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + "Continuer à regarder", + color = DesignColors.text, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(start = 10.dp, bottom = 6.dp) + ) + Text( + "Chargement des reprises…", + color = DesignColors.muted, + fontSize = 13.sp, + modifier = Modifier.padding(start = 10.dp) + ) + } + } + is UiState.Success -> { + if (itemsState.data.isEmpty()) return + Column(modifier = Modifier.fillMaxWidth()) { + Text( + "Continuer à regarder", + color = DesignColors.text, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(start = 10.dp, bottom = 10.dp) + ) + LazyRow( + modifier = Modifier.fillMaxWidth().height(132.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(itemsState.data, key = { it.id }) { item -> + ContinueWatchingCard( + item = item, + onFocused = onSectionFocused, + onPlay = { onPlay(item) } + ) + } + } + } + } + else -> return + } +} + +@Composable +private fun ContinueWatchingCard( + item: ContinueWatchingItem, + onFocused: () -> Unit = {}, + onPlay: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val fraction = item.progress.fraction.coerceIn(0f, 1f) + + Surface( + onClick = onPlay, + modifier = Modifier + .width(310.dp) + .height(124.dp) + .onFocusChanged { + val focused = it.isFocused || it.hasFocus + if (focused && !isFocused) onFocused() + isFocused = focused + } + .focusable() + .border( + width = if (isFocused) 3.dp else 1.dp, + color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f), + shape = RoundedCornerShape(18.dp) + ), + shape = RoundedCornerShape(18.dp), + color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel + ) { + Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(width = 86.dp, height = 96.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF151515)), + contentAlignment = Alignment.Center + ) { + if (!item.imageUrl.isNullOrBlank()) { + coil3.compose.AsyncImage( + model = item.imageUrl, + contentDescription = item.title, + modifier = Modifier.fillMaxSize() + ) + } else { + Text(item.title.take(2).uppercase(), color = DesignColors.orange, fontWeight = FontWeight.Black) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + item.title, + color = DesignColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(5.dp)) + Text( + item.subtitle, + color = DesignColors.muted, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(10.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(5.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFF444444)) + ) { + if (fraction > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(DesignColors.orange) + ) + } + } + } + } + } +} + +@Composable +private fun MainMenuTile( + modifier: Modifier = Modifier, + icon: String, + title: String, + subtitle: String, + accent: Color, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val focusAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(150) + ) + + Surface( + onClick = onClick, + modifier = modifier + .width(172.dp) + .height(158.dp) + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .focusable() + .border( + width = if (isFocused) 4.dp else 1.dp, + color = if (isFocused) accent.copy(alpha = focusAlpha) else DesignColors.strokeStrong.copy(alpha = 0.35f), + shape = RoundedCornerShape(26.dp) + ), + shape = RoundedCornerShape(26.dp), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = if (isFocused) { + listOf(accent.copy(alpha = 0.34f), DesignColors.panel, DesignColors.panel2) + } else { + listOf(Color(0x1FFFFFFF), DesignColors.panel, DesignColors.panel2) + } + ) + ) + .padding(18.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(58.dp) + .background( + Brush.linearGradient(listOf(accent, accent.copy(alpha = 0.55f))), + RoundedCornerShape(20.dp) + ) + .border( + BorderStroke(1.dp, Color.White.copy(alpha = 0.22f)), + RoundedCornerShape(20.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + icon, + color = Color.White, + fontSize = if (icon.length <= 2) 23.sp else 16.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center + ) + } + + Spacer(Modifier.height(13.dp)) + + Text( + title, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(Modifier.height(4.dp)) + + Text( + subtitle, + color = if (isFocused) DesignColors.text else DesignColors.muted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun MainMenuSettingsButton(onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val focusAlpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0f, + animationSpec = tween(150) + ) + + Surface( + onClick = onClick, + modifier = Modifier + .size(42.dp) + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .border( + width = if (isFocused) 3.dp else 1.dp, + color = if (isFocused) DesignColors.orange.copy(alpha = focusAlpha) else DesignColors.stroke, + shape = RoundedCornerShape(12.dp) + ), + shape = RoundedCornerShape(12.dp), + color = if (isFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF) + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("⚙", fontSize = 20.sp) + } + } +} + +@Composable +private fun MainMenuSettingsDialog(onDismiss: () -> Unit, onLogout: () -> Unit) { + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Paramètres", fontWeight = FontWeight.Bold, color = DesignColors.text) }, + text = { + Column(modifier = Modifier.widthIn(max = 440.dp)) { + if (config != null) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("Source : ", color = DesignColors.muted, fontSize = 14.sp) + Text( + if (config.sourceType == SourceType.XTREAM) "Xtream" else "M3U", + color = DesignColors.orange, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + if (config.sourceType == SourceType.XTREAM) { + MainMenuInfoRow("Serveur", config.serverUrl) + MainMenuInfoRow("Utilisateur", config.username) + } else { + MainMenuInfoRow("URL M3U", config.m3uUrl) + } + } + Spacer(Modifier.height(12.dp)) + Text( + "Appuyez sur Déconnecter pour changer de source.", + color = DesignColors.muted2, + fontSize = 12.sp + ) + } + }, + containerColor = DesignColors.bg2, + titleContentColor = DesignColors.text, + textContentColor = DesignColors.muted, + confirmButton = { + TextButton(onClick = { onLogout(); onDismiss() }) { + Text("Déconnecter", color = DesignColors.red) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Fermer", color = DesignColors.muted) } + } + ) +} + +@Composable +private fun MainMenuInfoRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("$label : ", color = DesignColors.muted, fontSize = 13.sp) + Text(value, color = DesignColors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..c0fe9fc --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt @@ -0,0 +1,553 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.view.ViewGroup +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel +import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository +import timber.log.Timber + +private const val VOD_SERIES_SEEK_STEP_MS = 10_000L + +@Composable +fun PlayerScreen( + initialStream: LiveStreamDto, + allStreams: List, + initialIndex: Int, + replayUrl: String? = null, + progressKey: String? = null, + progressKeys: List = emptyList(), + viewModel: PlayerViewModel = viewModel(), + onBack: () -> Unit = {} +) { + val context = LocalContext.current + val state by viewModel.state.collectAsStateWithLifecycle() + var player by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val progressRepository = remember(context) { PlaybackProgressRepository(context) } + val activeProgressKey = progressKeys.getOrNull(state.currentIndex) ?: progressKey + val isVodOrSeries = activeProgressKey != null + var currentPlayerProgressKey by remember { mutableStateOf(null) } + var youtubeVisible by remember { mutableStateOf(false) } + var isPlayerPaused by remember { mutableStateOf(false) } + var currentPositionMs by remember { mutableStateOf(0L) } + var currentDurationMs by remember { mutableStateOf(0L) } + + fun saveProgressAtKey(key: String?, positionMs: Long, durationMs: Long) { + if (key == null) return + val keyIndex = progressKeys.indexOf(key) + val metadataStream = if (keyIndex >= 0) { + allStreams.getOrNull(keyIndex) ?: state.stream + } else { + state.stream + } + progressRepository.saveContinueMetadata( + key = key, + stream = metadataStream, + // Ne pas sauvegarder toute la playlist d'une série toutes les 5 secondes : + // cela ralentit les petites box TV. La reprise reprend l'épisode courant. + allStreams = emptyList(), + currentIndex = 0, + progressKeys = emptyList(), + playbackUrl = replayUrl ?: metadataStream.directUrl.takeIf { it.isNotBlank() } + ) + progressRepository.saveProgress( + key = key, + positionMs = positionMs, + durationMs = normalizeDurationMs(durationMs) + ) + } + + fun saveProgressAt(positionMs: Long, durationMs: Long) { + saveProgressAtKey(currentPlayerProgressKey ?: activeProgressKey, positionMs, durationMs) + } + + fun saveCurrentProgress() { + val currentPlayer = player ?: return + saveProgressAt( + positionMs = currentPlayer.currentPosition, + durationMs = currentPlayer.duration + ) + } + + LaunchedEffect(initialStream) { + viewModel.initialize(initialStream, allStreams, initialIndex) + } + + // Use replay URL if provided, otherwise build live URL + val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) { + replayUrl ?: viewModel.buildUrl(state.stream) + } + + DisposableEffect(Unit) { + onDispose { + saveCurrentProgress() + player?.release() + } + } + + // Auto-hide overlay after 3 seconds + LaunchedEffect(state.showOverlay) { + if (state.showOverlay) { + delay(3000) + viewModel.hideOverlay() + } + } + + // Update player when channel changes, with resolution listener + LaunchedEffect(streamUrl, activeProgressKey) { + if (streamUrl.isNotEmpty()) { + val oldPlayer = player + val oldProgressKey = currentPlayerProgressKey + if (oldPlayer != null) { + saveProgressAtKey( + key = oldProgressKey, + positionMs = oldPlayer.currentPosition, + durationMs = oldPlayer.duration + ) + oldPlayer.stop() + oldPlayer.release() + } + + val resumePositionMs = activeProgressKey?.let { progressRepository.getProgress(it) } ?: 0L + currentPlayerProgressKey = activeProgressKey + currentPositionMs = resumePositionMs + currentDurationMs = 0L + + // User-Agent personnalisé pour les flux CDN/IPTV + val httpDataSourceFactory = DefaultHttpDataSource.Factory() + .setUserAgent("VLC/3.0.21 LibVLC/3.0.21") + .setAllowCrossProtocolRedirects(true) + val mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(httpDataSourceFactory) + + var firstErrorShown = false + + val newPlayer = ExoPlayer.Builder(context, DefaultRenderersFactory(context)) + .setMediaSourceFactory(mediaSourceFactory) + .build().apply { + volume = if (youtubeVisible) 0f else 1f + Timber.i("▶ Playing stream URL: %s", streamUrl) + setMediaItem(MediaItem.fromUri(streamUrl)) + if (resumePositionMs > 0L) { + seekTo(resumePositionMs) + } + addListener(object : Player.Listener { + override fun onVideoSizeChanged(videoSize: VideoSize) { + if (videoSize.width > 0 && videoSize.height > 0) { + val res = "${videoSize.width}x${videoSize.height}" + viewModel.updateResolution(res) + } + } + override fun onPlaybackStateChanged(playbackState: Int) { + currentDurationMs = normalizeDurationMs(this@apply.duration) + currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L) + if (playbackState == Player.STATE_READY) { + viewModel.resetFreezeCounter() + firstErrorShown = false + } + if (playbackState == Player.STATE_ENDED) { + val endedProgressKey = currentPlayerProgressKey ?: activeProgressKey + endedProgressKey?.let { progressRepository.markCompleted(it, currentDurationMs) } + currentPositionMs = 0L + isPlayerPaused = false + if (endedProgressKey?.startsWith("series:") == true) { + viewModel.playNextItem() + } + } + } + override fun onIsPlayingChanged(isPlaying: Boolean) { + isPlayerPaused = !isPlaying && this@apply.playbackState != Player.STATE_ENDED + if (isPlaying) { + viewModel.resetFreezeCounter() + } + currentDurationMs = normalizeDurationMs(this@apply.duration) + currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L) + } + override fun onPlayerError(error: PlaybackException) { + if (!firstErrorShown) { + firstErrorShown = true + Timber.w("Player error on ${state.stream.name}: ${error.errorCodeName} — ${error.message}") + } + // Si le replay TV échoue, essayer le live en fallback + if (replayUrl != null && activeProgressKey == null) { + player?.stop() + player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream))) + player?.prepare() + player?.play() + } + } + }) + prepare() + play() + } + player = newPlayer + + // Freeze detection : attend 15s de grâce au début, + // puis ne redémarre qu'après 10s de buffering continu + val startTime = System.currentTimeMillis() + while (isActive && viewModel.canRestart()) { + delay(5000) + val elapsed = System.currentTimeMillis() - startTime + if (elapsed < 15_000) continue // grâce de 15s au démarrage + val p = player + if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) { + delay(5000) + if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) { + viewModel.requestRestart() + } + } + } + } + } + + LaunchedEffect(player, youtubeVisible) { + player?.volume = if (youtubeVisible) 0f else 1f + } + + LaunchedEffect(player, activeProgressKey) { + if (activeProgressKey == null) return@LaunchedEffect + while (isActive) { + val currentPlayer = player + if (currentPlayer != null) { + currentPositionMs = currentPlayer.currentPosition.coerceAtLeast(0L) + currentDurationMs = normalizeDurationMs(currentPlayer.duration) + } + delay(1000) + } + } + + LaunchedEffect(player, activeProgressKey) { + if (activeProgressKey == null) return@LaunchedEffect + while (isActive) { + delay(5000) + saveCurrentProgress() + } + } + + // Request focus for remote-control key events + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + + val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + if (youtubeVisible) { + // IMPORTANT : quand le mini lecteur YouTube est ouvert, + // le parent ne consomme plus aucune touche. + // Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte. + // Cela évite les différences de focus entre modèles de Mi Box. + false + } else { + when (event.key) { + Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true } + Key.DirectionLeft -> { + if (isVodOrSeries) { + player?.let { p -> + val targetPosition = (p.currentPosition - VOD_SERIES_SEEK_STEP_MS).coerceAtLeast(0L) + p.seekTo(targetPosition) + val duration = normalizeDurationMs(p.duration) + currentPositionMs = targetPosition + currentDurationMs = duration + saveProgressAt(targetPosition, duration) + } + viewModel.showOverlay() + } else { + viewModel.zappingLeft() + } + true + } + Key.DirectionRight -> { + if (isVodOrSeries) { + player?.let { p -> + val targetPosition = p.currentPosition + VOD_SERIES_SEEK_STEP_MS + val duration = normalizeDurationMs(p.duration) + val finalPosition = if (duration > 0L) targetPosition.coerceAtMost(duration) else targetPosition + p.seekTo(finalPosition) + currentPositionMs = finalPosition + currentDurationMs = duration + saveProgressAt(finalPosition, duration) + } + viewModel.showOverlay() + } else { + viewModel.zappingRight() + } + true + } + Key.DirectionCenter, Key.Enter -> { + if (isVodOrSeries) { + player?.let { p -> + if (p.isPlaying) { + p.pause() + saveCurrentProgress() + } else { + p.play() + } + currentPositionMs = p.currentPosition.coerceAtLeast(0L) + currentDurationMs = normalizeDurationMs(p.duration) + isPlayerPaused = !p.isPlaying + } + viewModel.showOverlay() + } else { + viewModel.toggleOverlay() + } + true + } + Key.Back -> { + if (state.showOverlay) { + saveCurrentProgress() + player?.stop() + player?.release() + player = null + onBack() + } else { + viewModel.toggleOverlay() + } + true + } + else -> false + } + } + } else false + } + ) { + // Video player area + if (!isLoadingStream) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + useController = false + keepScreenOn = true + } + }, + update = { view -> view.player = player }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text("Chargement du flux…", color = Color.Gray) + } + } + } + + // Overlay bas : infos + avancement + raccourcis télécommande + AnimatedVisibility( + visible = state.showOverlay, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth() + ) { + Surface( + color = Color.Black.copy(alpha = 0.78f), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.Top) { + // Logo + if (state.stream.streamIcon != null) { + coil3.compose.AsyncImage( + model = state.stream.streamIcon, + contentDescription = state.stream.name, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + // Nom de la chaîne / vidéo + Text( + state.stream.name, + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Résolution + if (state.resolution.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium) + } + + if (isVodOrSeries && currentPositionMs > 0L) { + Spacer(Modifier.height(8.dp)) + PlaybackProgressInfo( + positionMs = currentPositionMs, + durationMs = currentDurationMs, + accentColor = Color(0xFFE50914) + ) + } + + // Programme en cours + if (state.currentProgram.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall) + Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + + // Programme suivant + if (state.nextProgram.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall) + Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + + if (isVodOrSeries) { + Spacer(Modifier.height(12.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.14f)) + ) + Spacer(Modifier.height(10.dp)) + if (isPlayerPaused) { + Text("Pause", color = Color.White, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Text("OK : Pause / Reprendre", color = Color.LightGray) + Text("◀ -10s", color = Color.LightGray) + Text("+10s ▶", color = Color.LightGray) + Text("Retour : menu", color = Color.LightGray) + } + } else if (allStreams.size > 1) { + Spacer(Modifier.height(12.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.14f)) + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Text("▲ YouTube discret", color = Color.LightGray) + Text("◀ Chaîne précédente", color = Color.LightGray) + Text("Retour : menu", color = Color.LightGray) + Text("Chaîne suivante ▶", color = Color.LightGray) + } + } + } + } + } + + if (youtubeVisible) { + YoutubeMiniPlayer( + onClose = { youtubeVisible = false }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp) + ) + } + } +} + + +@Composable +private fun PlaybackProgressInfo( + positionMs: Long, + durationMs: Long, + accentColor: Color +) { + val fraction = if (durationMs > 0L) { + (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + val percent = if (fraction > 0f) (fraction * 100f).toInt().coerceIn(1, 99) else 0 + Column(modifier = Modifier.fillMaxWidth()) { + Text("Avancement", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall) + Text( + if (durationMs > 0L && percent > 0) { + "${formatPlaybackTime(positionMs)} / ${formatPlaybackTime(durationMs)} • $percent%" + } else { + formatPlaybackTime(positionMs) + }, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(6.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(5.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFF444444)) + ) { + if (fraction > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(accentColor) + ) + } + } + } +} + +private fun normalizeDurationMs(durationMs: Long): Long { + return if (durationMs > 0L && durationMs < Long.MAX_VALUE / 2L) durationMs else 0L +} + +private fun formatPlaybackTime(ms: Long): String { + val totalSeconds = (ms / 1000L).coerceAtLeast(0L) + val hours = totalSeconds / 3600L + val minutes = (totalSeconds % 3600L) / 60L + val seconds = totalSeconds % 60L + return if (hours > 0L) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt new file mode 100644 index 0000000..7755e75 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt @@ -0,0 +1,112 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun ProgramGuideScreen( + viewModel: GuideViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val streamsState by viewModel.streams.collectAsStateWithLifecycle() + val epgState by viewModel.epg.collectAsStateWithLifecycle() + val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle() + + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back") } + Spacer(Modifier.width(16.dp)) + Text("TV Guide", style = MaterialTheme.typography.headlineMedium) + } + + when (val strs = streamsState) { + is UiState.Success -> { + val streamList = strs.data + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(streamList) { stream -> + Surface( + onClick = { viewModel.selectStream(stream.streamId) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cornerRadius = 10.dp + ), + shape = RoundedCornerShape(10.dp), + color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(stream.name, style = MaterialTheme.typography.bodyLarge) + if (stream.streamId == selectedStreamId) { + when (val epg = epgState) { + is UiState.Success -> { + epg.data.take(5).forEach { program -> + Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall) + else -> {} + } + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SuggestionChip( + onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Live") } + ) + SuggestionChip( + onClick = { onNavigate(NavState.ReplayGuide(stream)) }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Replay") } + ) + } + } + } + } + } + } + } + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + else -> {} + } + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt new file mode 100644 index 0000000..95f43ad --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt @@ -0,0 +1,96 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel + +@Composable +fun ReplayGuideScreen( + stream: LiveStreamDto, + viewModel: ReplayViewModel = viewModel(), + onBack: () -> Unit = {}, + onPlayReplay: (String) -> Unit = {} +) { + val epgState by viewModel.epg.collectAsStateWithLifecycle() + + LaunchedEffect(stream) { viewModel.loadReplay(stream) } + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back") } + Spacer(Modifier.width(16.dp)) + Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium) + } + + when (val epg = epgState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) } + is UiState.Success -> { + val grouped = epg.data.groupBy { it.start?.take(10) ?: "" } + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + grouped.forEach { (day, programs) -> + item { + Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp)) + } + items(programs) { program -> + Surface( + onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cornerRadius = 10.dp + ), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f)) { + Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SuggestionChip( + onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = MaterialTheme.colorScheme.primary, + borderWidth = 2.dp, + cornerRadius = 16.dp + ), + label = { Text("Revoir") } + ) + } + } + } + } + } + } + else -> {} + } + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt new file mode 100644 index 0000000..d101620 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt @@ -0,0 +1,483 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.EpisodeDto +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys +import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun SeriesScreen( + viewModel: SeriesViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val seriesState by viewModel.series.collectAsStateWithLifecycle() + val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle() + val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + val context = LocalContext.current + val progressRepository = remember(context) { PlaybackProgressRepository(context) } + var selectedSeries by remember { mutableStateOf(null) } + val seriesGridState = rememberLazyGridState() + val episodesListState = rememberLazyListState() + val categoryListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val seriesFocusRequesters = remember { mutableStateMapOf() } + + BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { + if (selectedSeries != null) selectedSeries = null + else onBack() + }, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = Color(0xFF4CAF50), + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) } + Spacer(Modifier.width(8.dp)) + Text(if (selectedSeries != null) selectedSeries!!.name else "Series", + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50)) + } + + // Categories (hidden in detail view) + if (selectedSeries == null) { + val catData = categoriesState + if (catData is UiState.Success) { + val catList = catData.data + LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) { + if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0) + } + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + state = categoryListState, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } } + item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } } + catList.forEach { cat -> + item(key = cat.categoryId) { + Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) } + } + } + } + } + } + + // Detail view + val detailSeries = selectedSeries + if (detailSeries != null) { + val info = seriesInfoState + when { + info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) } + info is UiState.Success -> { + var lastContentRowIndex = 0 + info.data.seasons.forEach { season -> + lastContentRowIndex += 1 + lastContentRowIndex += info.data.episodes[season.seasonNumber.toString()].orEmpty().size + } + val maxEpisodeRowIndex = (lastContentRowIndex - 1).coerceAtLeast(0) + val playableEpisodes = info.data.seasons.flatMap { season -> + info.data.episodes[season.seasonNumber.toString()].orEmpty() + } + val episodeProgressKeys = playableEpisodes.map { ep -> + "series:${detailSeries.seriesId}:${ep.id}" + } + + fun scrollToEpisodeRow(index: Int) { + val targetIndex = index.coerceIn(0, maxEpisodeRowIndex) + coroutineScope.launch { + episodesListState.animateScrollToItem(targetIndex) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .tvFastScrollKeys( + enabled = maxEpisodeRowIndex > 0, + onJumpToTop = { scrollToEpisodeRow(0) }, + onJumpToBottom = { scrollToEpisodeRow(maxEpisodeRowIndex) }, + onPageUp = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex - 12) }, + onPageDown = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex + 12) } + ), + state = episodesListState + ) { + info.data.seasons.forEach { season -> + item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) } + val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList() + items(episodes, key = { it.id }) { ep -> + val episodeProgressKey = "series:${detailSeries.seriesId}:${ep.id}" + val legacyEpisodeProgressKey = "series:${ep.id}" + val savedProgress = progressRepository.getPlaybackProgress(episodeProgressKey, legacyEpisodeProgressKey) + val progress = if (savedProgress.durationMs <= 0L) { + savedProgress.copy(durationMs = parseEpisodeDurationMs(ep.info?.duration)) + } else { + savedProgress + } + Surface(onClick = { + val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials() + if (config != null) { + playableEpisodes.forEach { playlistEpisode -> + progressRepository.migrateProgress( + fromKey = "series:${playlistEpisode.id}", + toKey = "series:${detailSeries.seriesId}:${playlistEpisode.id}" + ) + } + val urlBuilder = ma.tkgraph.xtreamtv.util.UrlBuilder(config) + val episodeStreams = playableEpisodes.map { playlistEpisode -> + ma.tkgraph.xtreamtv.data.model.LiveStreamDto( + name = episodePlayerTitle(detailSeries.name, playlistEpisode), + streamId = playlistEpisode.id, + streamIcon = playlistEpisode.info?.movieImage ?: detailSeries.cover, + containerExtension = playlistEpisode.containerExtension, + directUrl = urlBuilder.seriesUrl(playlistEpisode.id, playlistEpisode.containerExtension) + ) + } + val currentEpisodeIndex = playableEpisodes.indexOfFirst { it.id == ep.id }.coerceAtLeast(0) + val selectedStream = episodeStreams.getOrNull(currentEpisodeIndex) + if (selectedStream != null) { + onNavigate(NavState.Player( + stream = selectedStream, + allStreams = episodeStreams, + currentIndex = currentEpisodeIndex, + replayUrl = null, + returnState = NavState.Series(), + progressKey = episodeProgressKeys.getOrNull(currentEpisodeIndex) ?: episodeProgressKey, + progressKeys = episodeProgressKeys + )) + } + } + }, modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .tvFocusHighlight( + focusedBorderColor = Color(0xFF4CAF50), + cornerRadius = 10.dp + ), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + when { + progress.isCompleted -> { + EpisodeCompletedBadge(accentColor = Color(0xFF4CAF50)) + Spacer(Modifier.width(10.dp)) + } + progress.hasProgress -> { + EpisodeProgressBadge(progress = progress, accentColor = Color(0xFF4CAF50)) + Spacer(Modifier.width(10.dp)) + } + } + Column(modifier = Modifier.weight(1f)) { + Text("Ep ${ep.episodeNum}: ${episodeDisplayTitle(ep)}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White) + if (progress.isCompleted || progress.hasProgress) { + Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFF4CAF50), maxLines = 1) + } + } + Text("\u25B6", color = Color(0xFF4CAF50)) + } + } + } + } + } + } + else -> { Box(Modifier.weight(1f)) } // Idle state + } + return@Surface + } + + // Grid + val s = seriesState + when { + s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) } + s is UiState.Success -> { + val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data + + fun focusSeriesAt(index: Int) { + val target = seriesList.getOrNull(index) ?: return + try { seriesFocusRequesters[target.seriesId]?.requestFocus() } catch (_: Exception) {} + } + + fun scrollToSeriesIndex(index: Int) { + if (seriesList.isEmpty()) return + val targetIndex = index.coerceIn(0, seriesList.lastIndex) + coroutineScope.launch { + seriesGridState.animateScrollToItem(targetIndex) + kotlinx.coroutines.delay(80) + focusSeriesAt(targetIndex) + } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(160.dp), + state = seriesGridState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .tvFastScrollKeys( + enabled = seriesList.isNotEmpty(), + onJumpToTop = { scrollToSeriesIndex(0) }, + onJumpToBottom = { scrollToSeriesIndex(seriesList.lastIndex) }, + onPageUp = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex - 15) }, + onPageDown = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex + 15) } + ), + contentPadding = PaddingValues(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(seriesList, key = { it.seriesId }) { series -> + val seriesFocusRequester = remember(series.seriesId) { + seriesFocusRequesters.getOrPut(series.seriesId) { FocusRequester() } + } + val hasSeriesProgress = progressRepository.hasAnyProgressWithPrefix("series:${series.seriesId}:") + Surface(onClick = { viewModel.recordSeriesUsage(series.seriesId); selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.focusRequester(seriesFocusRequester).fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) { + var favFocused by remember { mutableStateOf(false) } + Box { + Column { + Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) { + if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))) + else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold) + if (hasSeriesProgress) { + SeriesInProgressBadge( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + ) + } + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White) + } + } + IconButton( + onClick = { viewModel.toggleFavorite(series.seriesId) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .onFocusChanged { favFocused = it.isFocused } + .border( + width = if (favFocused) 2.dp else 0.dp, + color = if (favFocused) Color(0xFFFFD700) else Color.Transparent, + shape = RoundedCornerShape(50) + ) + ) { + Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555)) + } + } + } + } + } + } + else -> { Box(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val bgColor = when { + isSelected -> accentColor + isFocused -> Color(0xFF1A3A1A) + else -> Color(0xFF252525) + } + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 3.dp, + color = when { + isFocused -> accentColor + else -> Color.Transparent + }, + shape = RoundedCornerShape(20.dp) + ), + shape = RoundedCornerShape(20.dp), + color = bgColor + ) { + Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp) + } +} + + +@Composable +private fun SeriesInProgressBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(Color.Black.copy(alpha = 0.74f)) + .padding(horizontal = 8.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFF4CAF50)) + ) + Spacer(Modifier.width(7.dp)) + Text( + "En cours de visionnage", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun EpisodeCompletedBadge( + accentColor: Color +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accentColor.copy(alpha = 0.26f)) + .border(1.dp, accentColor, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("✓", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) + Spacer(Modifier.width(6.dp)) + Text("Déjà vu", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp, maxLines = 1) + } +} + +@Composable +private fun EpisodeProgressBadge( + progress: PlaybackProgressSnapshot, + accentColor: Color +) { + Column( + modifier = Modifier + .width(74.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + maxLines = 1 + ) + Spacer(Modifier.height(4.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFF444444)) + ) { + if (progress.fraction > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress.fraction) + .background(accentColor) + ) + } + } + } +} + +private fun progressLabel(progress: PlaybackProgressSnapshot): String { + if (progress.isCompleted) return "Épisode terminé" + val position = formatPlaybackTime(progress.positionMs) + return if (progress.durationMs > 0L && progress.percent > 0) { + "$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%" + } else { + "Reprise à $position" + } +} + +private fun episodeDisplayTitle(ep: EpisodeDto): String { + return ep.title ?: ep.name ?: ep.episodeName ?: "" +} + +private fun episodePlayerTitle(seriesName: String, ep: EpisodeDto): String { + val title = episodeDisplayTitle(ep) + return if (title.isBlank()) { + "$seriesName - Ep ${ep.episodeNum}" + } else { + "$seriesName - Ep ${ep.episodeNum}: $title" + } +} + +private fun parseEpisodeDurationMs(duration: String?): Long { + if (duration.isNullOrBlank()) return 0L + val trimmed = duration.trim() + val parts = trimmed.split(":").mapNotNull { it.toLongOrNull() } + if (parts.isEmpty()) return 0L + val seconds = when (parts.size) { + 3 -> parts[0] * 3600L + parts[1] * 60L + parts[2] + 2 -> parts[0] * 60L + parts[1] + 1 -> parts[0] * 60L + else -> 0L + } + return seconds.coerceAtLeast(0L) * 1000L +} + +private fun formatPlaybackTime(ms: Long): String { + val totalSeconds = (ms / 1000L).coerceAtLeast(0L) + val hours = totalSeconds / 3600L + val minutes = (totalSeconds % 3600L) / 60L + val seconds = totalSeconds % 60L + return if (hours > 0L) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt new file mode 100644 index 0000000..fac719b --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt @@ -0,0 +1,336 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.foundation.border +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot +import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight +import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys +import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel +import ma.tkgraph.xtreamtv.ui.navigation.NavState + +@Composable +fun VodScreen( + viewModel: VodViewModel = viewModel(), + onNavigate: (NavState) -> Unit = {}, + onBack: () -> Unit = {} +) { + val categoriesState by viewModel.categories.collectAsStateWithLifecycle() + val moviesState by viewModel.movies.collectAsStateWithLifecycle() + val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + val context = LocalContext.current + val progressRepository = remember(context) { PlaybackProgressRepository(context) } + val gridState = rememberLazyGridState() + val categoryListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val movieFocusRequesters = remember { mutableStateMapOf() } + + BackHandler { onBack() } + + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = onBack, + modifier = Modifier.tvFocusHighlight( + focusedBorderColor = Color(0xFFE50914), + borderWidth = 2.dp, + cornerRadius = 8.dp + ) + ) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) } + Spacer(Modifier.width(8.dp)) + Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914)) + } + + // Categories with ★ Favorites + val catsState = categoriesState + when (catsState) { + is UiState.Success -> { + val catList = catsState.data + LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) { + if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0) + } + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + state = categoryListState, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } } + item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } } + catList.forEach { cat -> + item(key = cat.categoryId) { + CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) } + } + } + } + } + else -> {} + } + + // Movies grid with posters + when (val movies = moviesState) { + is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) } + is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) } + is UiState.Success -> { + val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data + + fun focusMovieAt(index: Int) { + val target = movieList.getOrNull(index) ?: return + try { movieFocusRequesters[target.streamId]?.requestFocus() } catch (_: Exception) {} + } + + fun scrollToMovieIndex(index: Int) { + if (movieList.isEmpty()) return + val targetIndex = index.coerceIn(0, movieList.lastIndex) + coroutineScope.launch { + gridState.animateScrollToItem(targetIndex) + kotlinx.coroutines.delay(80) + focusMovieAt(targetIndex) + } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(160.dp), + state = gridState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .tvFastScrollKeys( + enabled = movieList.isNotEmpty(), + onJumpToTop = { scrollToMovieIndex(0) }, + onJumpToBottom = { scrollToMovieIndex(movieList.lastIndex) }, + onPageUp = { scrollToMovieIndex(gridState.firstVisibleItemIndex - 15) }, + onPageDown = { scrollToMovieIndex(gridState.firstVisibleItemIndex + 15) } + ), + contentPadding = PaddingValues(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(movieList, key = { it.streamId }) { movie -> + val movieFocusRequester = remember(movie.streamId) { + movieFocusRequesters.getOrPut(movie.streamId) { FocusRequester() } + } + val progress = progressRepository.getPlaybackProgress("vod:${movie.streamId}") + MovieCard( + modifier = Modifier.focusRequester(movieFocusRequester), + name = movie.name, + posterUrl = movie.streamIcon, + rating = movie.rating, + progress = progress, + isFavorite = movie.streamId in favoriteIds, + onToggleFav = { viewModel.toggleFavorite(movie.streamId) }, + onPlay = { + viewModel.recordMovieUsage(movie.streamId) + val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials() + if (config != null) { + val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension) + onNavigate(NavState.Player( + stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto( + name = movie.name, + streamId = movie.streamId, + streamIcon = movie.streamIcon, + containerExtension = movie.containerExtension + ), + allStreams = emptyList(), + currentIndex = 0, + replayUrl = url, + returnState = NavState.Vod(), + progressKey = "vod:${movie.streamId}" + )) + } + } + ) + } + } + } + else -> {} + } + } + } +} + +@Composable +private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) { + var isFocused by remember { mutableStateOf(false) } + val bgColor = when { + isSelected -> Color(0xFFE50914) + isFocused -> Color(0xFF3A1A1A) + else -> Color(0xFF252525) + } + Surface( + onClick = onClick, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 3.dp, + color = when { + isFocused -> Color(0xFFE50914) + else -> Color.Transparent + }, + shape = RoundedCornerShape(20.dp) + ), + shape = RoundedCornerShape(20.dp), + color = bgColor + ) { + Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp) + } +} + +@Composable +private fun MovieCard( + modifier: Modifier = Modifier, + name: String, + posterUrl: String?, + rating: String?, + progress: PlaybackProgressSnapshot, + isFavorite: Boolean, + onToggleFav: () -> Unit, + onPlay: () -> Unit +) { + var favFocused by remember { mutableStateOf(false) } + + Surface( + onClick = onPlay, + modifier = modifier + .fillMaxWidth() + .tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp), + shape = RoundedCornerShape(12.dp), + color = Color(0xFF1A1A1A), + tonalElevation = 2.dp + ) { + Box { + Column { + // Poster + Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) { + if (posterUrl != null) { + coil3.compose.AsyncImage( + model = posterUrl, + contentDescription = name, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + ) + } else { + Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold) + } + if (progress.hasProgress) { + ProgressOverlay( + progress = progress, + accentColor = Color(0xFFE50914), + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth() + ) + } + } + // Info bar + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White) + if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA)) + if (progress.hasProgress) { + Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFFE50914), maxLines = 1) + } + } + } + } + // Favorite icon in top-right corner (doesn't steal card focus) + IconButton( + onClick = onToggleFav, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .onFocusChanged { favFocused = it.isFocused } + .border( + width = if (favFocused) 2.dp else 0.dp, + color = if (favFocused) Color(0xFFFFD700) else Color.Transparent, + shape = RoundedCornerShape(50) + ) + ) { + Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555)) + } + } + } +} + + +@Composable +private fun ProgressOverlay( + progress: PlaybackProgressSnapshot, + accentColor: Color, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(Color.Black.copy(alpha = 0.72f)) + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(50)) + .background(Color(0xFF444444)) + ) { + if (progress.fraction > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress.fraction) + .background(accentColor) + ) + } + } + Spacer(Modifier.height(4.dp)) + Text(progressLabel(progress), color = Color.White, fontSize = 11.sp, maxLines = 1) + } +} + +private fun progressLabel(progress: PlaybackProgressSnapshot): String { + val position = formatPlaybackTime(progress.positionMs) + return if (progress.durationMs > 0L && progress.percent > 0) { + "$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%" + } else { + "Reprise à $position" + } +} + +private fun formatPlaybackTime(ms: Long): String { + val totalSeconds = (ms / 1000L).coerceAtLeast(0L) + val hours = totalSeconds / 3600L + val minutes = (totalSeconds % 3600L) / 60L + val seconds = totalSeconds % 60L + return if (hours > 0L) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt new file mode 100644 index 0000000..ba6110d --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt @@ -0,0 +1,545 @@ +package ma.tkgraph.xtreamtv.ui.screens + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem +import ma.tkgraph.xtreamtv.ui.theme.DesignColors +import ma.tkgraph.xtreamtv.ui.viewmodels.YoutubeViewModel +import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils + +@Composable +fun YoutubeScreen( + viewModel: YoutubeViewModel = viewModel(), + onBack: () -> Unit +) { + val videos by viewModel.videos.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + var titleInput by remember { mutableStateOf("") } + var linkInput by remember { mutableStateOf(YoutubeUrlUtils.DEFAULT_WATCH_PREFIX) } + var playingVideo by remember { mutableStateOf(null) } + val linkFocusRequester = remember { FocusRequester() } + + BackHandler { + if (playingVideo != null) { + playingVideo = null + } else { + onBack() + } + } + + if (playingVideo != null) { + YoutubeFullPlayer( + video = playingVideo!!, + onClose = { playingVideo = null } + ) + return + } + + LaunchedEffect(Unit) { + runCatching { linkFocusRequester.requestFocus() } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 42.dp, vertical = 28.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "YouTube", + style = MaterialTheme.typography.displaySmall.copy( + brush = Brush.linearGradient(listOf(DesignColors.red, DesignColors.orange2)) + ), + fontWeight = FontWeight.Black + ) + Text( + "Ajoutez des liens ou codes YouTube, puis lancez-les depuis la TV", + color = DesignColors.muted, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + + TextButton(onClick = onBack) { + Text("Retour", color = DesignColors.muted) + } + } + + Spacer(Modifier.height(20.dp)) + + YoutubeAddPanel( + titleInput = titleInput, + onTitleChange = { titleInput = it }, + linkInput = linkInput, + onLinkChange = { linkInput = it }, + linkFocusRequester = linkFocusRequester, + message = message, + onAdd = { + val added = viewModel.addVideo(titleInput, linkInput) + if (added) { + titleInput = "" + linkInput = YoutubeUrlUtils.DEFAULT_WATCH_PREFIX + } + }, + onClearMessage = { viewModel.clearMessage() } + ) + + Spacer(Modifier.height(18.dp)) + + Text( + "Vidéos enregistrées", + color = DesignColors.text, + fontSize = 22.sp, + fontWeight = FontWeight.Black + ) + + Spacer(Modifier.height(10.dp)) + + if (videos.isEmpty()) { + YoutubeEmptyState() + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(videos, key = { it.id }) { video -> + YoutubeVideoRow( + video = video, + onPlay = { + viewModel.markPlayed(video.id) + playingVideo = video + }, + onDelete = { viewModel.deleteVideo(video.id) } + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + + Text( + "OK pour lire • BACK pour revenir • Certains créateurs bloquent l'embed YouTube", + color = DesignColors.muted2, + fontSize = 12.sp + ) + } + } +} + +@Composable +private fun YoutubeAddPanel( + titleInput: String, + onTitleChange: (String) -> Unit, + linkInput: String, + onLinkChange: (String) -> Unit, + linkFocusRequester: FocusRequester, + message: String?, + onAdd: () -> Unit, + onClearMessage: () -> Unit +) { + Surface( + color = DesignColors.panel, + shape = RoundedCornerShape(22.dp), + border = BorderStroke(1.dp, DesignColors.strokeStrong.copy(alpha = 0.35f)) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = linkInput, + onValueChange = onLinkChange, + modifier = Modifier + .weight(1.5f) + .focusRequester(linkFocusRequester), + singleLine = true, + label = { Text("Lien ou code YouTube") }, + placeholder = { Text("https://www.youtube.com/watch?v=...") } + ) + + OutlinedTextField( + value = titleInput, + onValueChange = onTitleChange, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Titre optionnel") }, + placeholder = { Text("Clip, documentaire...") } + ) + + Button( + onClick = onAdd, + colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red) + ) { + Text("Ajouter") + } + } + + if (!message.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = message, + color = if (message.contains("impossible", ignoreCase = true) || message.contains("invalide", ignoreCase = true)) DesignColors.red else DesignColors.green, + fontSize = 13.sp, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClearMessage) { + Text("OK", color = DesignColors.muted) + } + } + } + } + } +} + +@Composable +private fun YoutubeEmptyState() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(190.dp) + .background(DesignColors.panel, RoundedCornerShape(20.dp)) + .border(1.dp, DesignColors.stroke, RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Aucune vidéo YouTube enregistrée", color = DesignColors.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("Collez un lien ou juste le code vidéo, puis appuyez sur Ajouter.", color = DesignColors.muted) + } + } +} + +@Composable +private fun YoutubeVideoRow( + video: YoutubeVideoItem, + onPlay: () -> Unit, + onDelete: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + + Surface( + onClick = onPlay, + modifier = Modifier + .fillMaxWidth() + .height(118.dp) + .onFocusChanged { isFocused = it.isFocused || it.hasFocus } + .focusable() + .border( + width = if (isFocused) 3.dp else 1.dp, + color = if (isFocused) DesignColors.red else DesignColors.stroke.copy(alpha = 0.55f), + shape = RoundedCornerShape(18.dp) + ), + shape = RoundedCornerShape(18.dp), + color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel + ) { + Row( + modifier = Modifier.fillMaxSize().padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(width = 154.dp, height = 88.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + if (!video.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = video.thumbnailUrl, + contentDescription = video.title, + modifier = Modifier.fillMaxSize() + ) + } else { + Text("YT", color = DesignColors.red, fontWeight = FontWeight.Black, fontSize = 24.sp) + } + } + + Spacer(Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + video.title, + color = DesignColors.text, + fontSize = 19.sp, + fontWeight = FontWeight.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(5.dp)) + Text( + video.url, + color = DesignColors.muted, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(5.dp)) + Text( + if (video.lastPlayedAtMs > 0L) "Dernière lecture enregistrée" else "Jamais lu", + color = DesignColors.muted2, + fontSize = 12.sp + ) + } + + Spacer(Modifier.width(12.dp)) + + Button( + onClick = onPlay, + colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red) + ) { + Text("Lire") + } + + Spacer(Modifier.width(8.dp)) + + TextButton(onClick = onDelete) { + Text("Supprimer", color = DesignColors.muted) + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun YoutubeFullPlayer( + video: YoutubeVideoItem, + onClose: () -> Unit +) { + val target = remember(video.url) { YoutubeUrlUtils.parse(video.url) } + var status by remember(video.id) { mutableStateOf("Chargement YouTube") } + var webView by remember { mutableStateOf(null) } + + BackHandler { onClose() } + + fun sendCommand(js: String) { + webView?.evaluateJavascript(js, null) + } + + DisposableEffect(Unit) { + onDispose { + webView?.stopLoading() + webView?.destroy() + webView = null + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + when (event.key) { + Key.Back -> { + onClose() + true + } + Key.DirectionCenter, Key.Enter -> { + sendCommand("if(window.togglePlay){togglePlay();}") + true + } + Key.DirectionLeft -> { + sendCommand("if(window.seekBy){seekBy(-10);}") + true + } + Key.DirectionRight -> { + sendCommand("if(window.seekBy){seekBy(10);}") + true + } + else -> false + } + } + .focusable() + ) { + if (target == null) { + Text( + "Lien YouTube invalide", + color = DesignColors.text, + modifier = Modifier.align(Alignment.Center) + ) + } else { + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(false) + settings.userAgentString = YoutubeUrlUtils.DESKTOP_USER_AGENT + + runCatching { + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + } + + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + val message = consoleMessage?.message().orEmpty() + if (message.startsWith("IMEDIA_YT:")) { + status = message.removePrefix("IMEDIA_YT:") + return true + } + return super.onConsoleMessage(consoleMessage) + } + } + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean = false + + override fun onPageFinished(view: WebView?, url: String?) { + if (!status.startsWith("YouTube erreur")) { + status = "Player chargé : OK Play/Pause, ←/→ -10s/+10s" + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + if (request?.isForMainFrame == true) { + status = "Erreur WebView : ${error?.description ?: "chargement impossible"}" + } + } + } + setBackgroundColor(android.graphics.Color.BLACK) + }.also { webView = it } + }, + update = { view -> + webView = view + if (view.tag != video.id) { + view.tag = video.id + if (target.canUseEmbed) { + view.loadDataWithBaseURL( + YoutubeUrlUtils.WEBVIEW_ORIGIN, + YoutubeUrlUtils.buildIframeHtml(target), + "text/html", + "utf-8", + target.toEmbedUrl() + ) + } else { + view.loadUrl(target.toWatchOrNormalizedUrl(), YoutubeUrlUtils.youtubeHeaders()) + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + Surface( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .height(78.dp), + color = Color.Black.copy(alpha = 0.58f) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(video.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(status, color = Color(0xFFB8C7D1), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button(onClick = { sendCommand("if(window.seekBy){seekBy(-10);}") }) { Text("-10s") } + Spacer(Modifier.width(8.dp)) + Button(onClick = { sendCommand("if(window.togglePlay){togglePlay();}") }) { Text("Play/Pause") } + Spacer(Modifier.width(8.dp)) + Button(onClick = { sendCommand("if(window.seekBy){seekBy(10);}") }) { Text("+10s") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = onClose, + colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red) + ) { Text("Fermer") } + } + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt new file mode 100644 index 0000000..eebe5da --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/theme/XtreamTheme.kt @@ -0,0 +1,54 @@ +package ma.tkgraph.xtreamtv.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// ─── Couleurs du design iMedia TV+ ─── +object DesignColors { + val bg1 = Color(0xFF020817) + val bg2 = Color(0xFF071a3d) + val bg3 = Color(0xFF2b0f53) + val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74) + val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70) + val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12) + val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42) + val text = Color(0xFFF7F8FF) + val muted = Color(0xFFA9B2D8) + val muted2 = Color(0xFF7F8AB6) + val orange = Color(0xFFFF512F) + val orange2 = Color(0xFFFFB000) + val blue = Color(0xFF1469FF) + val blue2 = Color(0xFF09B7FF) + val red = Color(0xFFFF3D64) + val green = Color(0xFF00D9B2) + val purple = Color(0xFF875CFF) + val liveRed = Color(0xFFFE3152) + val liveRedDark = Color(0xFFBA1733) +} + +private val DesignColorScheme = darkColorScheme( + primary = DesignColors.orange, + onPrimary = Color.White, + primaryContainer = DesignColors.orange.copy(alpha = 0.2f), + secondary = DesignColors.blue, + onSecondary = Color.White, + surface = Color(0xBD1A1C49), + surfaceVariant = Color(0x70121D49), + onSurface = DesignColors.text, + onSurfaceVariant = DesignColors.muted, + background = DesignColors.bg1, + onBackground = DesignColors.text, + error = DesignColors.red, + onError = Color.White, + outline = DesignColors.stroke +) + +@Composable +fun XtreamTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = DesignColorScheme, + content = content + ) +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt new file mode 100644 index 0000000..b79f437 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt @@ -0,0 +1,296 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpisodeDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.util.UrlBuilder + +class GlobalSearchViewModel : ViewModel() { + + private val container = XtreamApplication.instance.appContainer + private var searchJob: Job? = null + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _scope = MutableStateFlow(SearchScope.Live) + val scope: StateFlow = _scope.asStateFlow() + + private val _results = MutableStateFlow>>(UiState.Success(emptyList())) + val results: StateFlow>> = _results.asStateFlow() + + fun setQuery(value: String) { + _query.value = value + } + + fun setScope(value: SearchScope) { + _scope.value = value + _results.value = UiState.Success(emptyList()) + } + + fun runSearch() { + val rawQuery = _query.value.trim() + if (rawQuery.normalizeForSearch().length < 2) { + _results.value = UiState.Success(emptyList()) + return + } + + val selectedScope = _scope.value + searchJob?.cancel() + searchJob = viewModelScope.launch(Dispatchers.IO) { + _results.value = UiState.Loading + val accumulated = mutableListOf() + + if (selectedScope.includesLive) { + val live = getLiveCatalog() + accumulated += filterLive(live, rawQuery) + if (accumulated.isNotEmpty()) { + _results.value = UiState.Success(accumulated.toList()) + } + } + + if (selectedScope.includesVod) { + val vod = getVodCatalog() + accumulated += filterVod(vod, rawQuery) + if (accumulated.isNotEmpty()) { + _results.value = UiState.Success(accumulated.toList()) + } + } + + if (selectedScope.includesSeries) { + val series = getSeriesCatalog() + accumulated += filterSeries(series, rawQuery) + if (accumulated.isNotEmpty()) { + _results.value = UiState.Success(accumulated.toList()) + } + } + + _results.value = UiState.Success(accumulated.toList()) + } + } + + fun recordLiveUsage(stream: LiveStreamDto) { + container.usageRepository.recordLiveStream(stream.streamId) + container.recentLiveRepository.record(stream) + } + + fun recordLiveUsage(streamId: Int) { + container.usageRepository.recordLiveStream(streamId) + } + + fun recordVodUsage(streamId: Int) { + container.usageRepository.recordVodItem(streamId) + } + + fun recordSeriesUsage(seriesId: Int) { + container.usageRepository.recordSeriesItem(seriesId) + } + + fun buildVodStream(movie: VodDto): LiveStreamDto? { + val config = container.secureStorage.getCredentials() ?: return null + val urlBuilder = UrlBuilder(config) + return LiveStreamDto( + name = movie.name, + streamId = movie.streamId, + streamIcon = movie.streamIcon, + containerExtension = movie.containerExtension, + directUrl = urlBuilder.vodUrl(movie.streamId, movie.containerExtension) + ) + } + + suspend fun buildSeriesLaunch(series: SeriesDto): SeriesLaunchData? { + val config = container.secureStorage.getCredentials() ?: return null + val urlBuilder = UrlBuilder(config) + val info = when (val result = container.seriesRepository.getSeriesInfo(series.seriesId)) { + is UiState.Success -> result.data + else -> return null + } + val playableEpisodes = info.seasons.flatMap { season -> + info.episodes[season.seasonNumber.toString()].orEmpty() + } + if (playableEpisodes.isEmpty()) return null + val streams = playableEpisodes.map { episode -> + LiveStreamDto( + name = episodePlayerTitle(series.name, episode), + streamId = episode.id, + streamIcon = episode.info?.movieImage ?: series.cover, + containerExtension = episode.containerExtension, + directUrl = urlBuilder.seriesUrl(episode.id, episode.containerExtension) + ) + } + val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" } + return SeriesLaunchData( + stream = streams.first(), + allStreams = streams, + currentIndex = 0, + progressKey = progressKeys.firstOrNull(), + progressKeys = progressKeys + ) + } + + private suspend fun getLiveCatalog(): List { + cachedLiveStreams?.let { return it } + val loaded = when (val result = container.liveRepository.getStreams(null)) { + is UiState.Success -> result.data + else -> emptyList() + } + cachedLiveStreams = loaded + return loaded + } + + private suspend fun getVodCatalog(): List { + cachedVodItems?.let { return it } + val loaded = when (val result = container.vodRepository.getMovies(null)) { + is UiState.Success -> result.data + else -> emptyList() + } + cachedVodItems = loaded + return loaded + } + + private suspend fun getSeriesCatalog(): List { + cachedSeriesItems?.let { return it } + val loaded = when (val result = container.seriesRepository.getSeriesList(null)) { + is UiState.Success -> result.data + else -> emptyList() + } + cachedSeriesItems = loaded + return loaded + } + + private fun filterLive(items: List, rawQuery: String): List { + val normalizedQuery = rawQuery.normalizeForSearch() + return items.asSequence() + .filter { it.name.normalizeForSearch().contains(normalizedQuery) } + .take(PER_TYPE_LIMIT) + .map { + GlobalSearchResult( + id = "live:${it.streamId}", + type = GlobalSearchResultType.Live, + title = it.name, + subtitle = "LIVE • Chaîne TV", + imageUrl = it.streamIcon, + liveStream = it + ) + } + .toList() + } + + private fun filterVod(items: List, rawQuery: String): List { + val normalizedQuery = rawQuery.normalizeForSearch() + return items.asSequence() + .filter { item -> + item.name.normalizeForSearch().contains(normalizedQuery) || + item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) || + item.year.orEmpty().contains(rawQuery) + } + .take(PER_TYPE_LIMIT) + .map { + GlobalSearchResult( + id = "vod:${it.streamId}", + type = GlobalSearchResultType.Vod, + title = it.name, + subtitle = listOfNotNull("VOD", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "), + imageUrl = it.streamIcon, + vod = it + ) + } + .toList() + } + + private fun filterSeries(items: List, rawQuery: String): List { + val normalizedQuery = rawQuery.normalizeForSearch() + return items.asSequence() + .filter { item -> + item.name.normalizeForSearch().contains(normalizedQuery) || + item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) || + item.year.orEmpty().contains(rawQuery) + } + .take(PER_TYPE_LIMIT) + .map { + GlobalSearchResult( + id = "series:${it.seriesId}", + type = GlobalSearchResultType.Series, + title = it.name, + subtitle = listOfNotNull("SÉRIE", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "), + imageUrl = it.cover, + series = it + ) + } + .toList() + } + + companion object { + private const val PER_TYPE_LIMIT = 30 + private var cachedLiveStreams: List? = null + private var cachedVodItems: List? = null + private var cachedSeriesItems: List? = null + } +} + +enum class SearchScope(val label: String) { + Live("LIVE"), + Vod("VOD"), + Series("SÉRIES"), + All("ALL"); + + val includesLive: Boolean get() = this == Live || this == All + val includesVod: Boolean get() = this == Vod || this == All + val includesSeries: Boolean get() = this == Series || this == All +} + +data class GlobalSearchResult( + val id: String, + val type: GlobalSearchResultType, + val title: String, + val subtitle: String, + val imageUrl: String?, + val liveStream: LiveStreamDto? = null, + val vod: VodDto? = null, + val series: SeriesDto? = null +) + +enum class GlobalSearchResultType { Live, Vod, Series } + +data class SeriesLaunchData( + val stream: LiveStreamDto, + val allStreams: List, + val currentIndex: Int, + val progressKey: String?, + val progressKeys: List +) + +private fun String.normalizeForSearch(): String = trim().lowercase() + .replace("é", "e") + .replace("è", "e") + .replace("ê", "e") + .replace("ë", "e") + .replace("à", "a") + .replace("â", "a") + .replace("î", "i") + .replace("ï", "i") + .replace("ô", "o") + .replace("ö", "o") + .replace("ù", "u") + .replace("û", "u") + .replace("ü", "u") + .replace("ç", "c") + +private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: "" + +private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String { + val title = episode.displayTitle() + return if (title.isBlank()) "$seriesName - Ep ${episode.episodeNum}" + else "$seriesName - Ep ${episode.episodeNum}: $title" +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt new file mode 100644 index 0000000..2c32f9e --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GuideViewModel.kt @@ -0,0 +1,44 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class GuideViewModel : ViewModel() { + + private val liveRepo = XtreamApplication.instance.appContainer.liveRepository + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + + private val _streams = MutableStateFlow>>(UiState.Success(emptyList())) + val streams: StateFlow>> = _streams.asStateFlow() + + private val _selectedStreamId = MutableStateFlow(null) + val selectedStreamId: StateFlow = _selectedStreamId.asStateFlow() + + private val _epg = MutableStateFlow>>(UiState.Idle) + val epg: StateFlow>> = _epg.asStateFlow() + + init { loadStreams() } + + fun loadStreams() { + viewModelScope.launch { + val r = liveRepo.getStreams() + if (r is UiState.Success) _streams.value = r + } + } + + fun selectStream(streamId: Int) { + _selectedStreamId.value = streamId + viewModelScope.launch { + val r = epgRepo.getFullGuide(streamId) + if (r is UiState.Success) _epg.value = r + } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt new file mode 100644 index 0000000..7f87c13 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt @@ -0,0 +1,134 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils + +class HomeViewModel : ViewModel() { + + private val liveRepo = XtreamApplication.instance.appContainer.liveRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + private val usageRepo = XtreamApplication.instance.appContainer.usageRepository + private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository + + private val _categories = MutableStateFlow>>( + UiState.Success(emptyList()) + ) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _streams = MutableStateFlow>>( + UiState.Success(emptyList()) + ) + val streams: StateFlow>> = _streams.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + // État réactif pour les favoris et l'épinglage + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId()) + val pinnedChannelId: StateFlow = _pinnedChannelId.asStateFlow() + + init { + loadCategories() + loadStreams() + } + + fun loadCategories() { + viewModelScope.launch { + val result = liveRepo.getCategories() + if (result is UiState.Success) { + _categories.value = UiState.Success(sortCategories(result.data)) + } else if (result is UiState.Error) { + _categories.value = UiState.Success(emptyList()) + } + } + } + + fun selectCategory(categoryId: Int?) { + usageRepo.recordLiveCategory(categoryId) + _selectedCategoryId.value = categoryId + refreshCategoryOrder() + if (categoryId == -1) { + // ★ Favorites : charger toutes les chaînes, filtrer dans l'UI + loadStreams(null) + } else { + loadStreams(categoryId) + } + } + + fun recordChannelUsage(stream: LiveStreamDto) { + usageRepo.recordLiveStream(stream.streamId) + recentLiveRepo.record(stream) + refreshStreamOrder() + } + + fun recordChannelUsage(streamId: Int) { + usageRepo.recordLiveStream(streamId) + refreshStreamOrder() + } + + fun loadStreams(categoryId: Int? = null) { + viewModelScope.launch { + val result = liveRepo.getStreams(categoryId) + if (result is UiState.Success) { + _streams.value = UiState.Success(sortStreams(result.data)) + } else if (result is UiState.Error) { + _streams.value = UiState.Success(emptyList()) + } + } + } + + fun toggleFavorite(streamId: Int) { + favRepo.toggleFavoriteLive(streamId) + _favoriteIds.value = favRepo.getFavoriteLiveIds() + } + + fun togglePin(streamId: Int) { + val current = _pinnedChannelId.value + if (current == streamId) { + favRepo.setPinnedChannelId(null) + _pinnedChannelId.value = null + } else { + favRepo.setPinnedChannelId(streamId) + _pinnedChannelId.value = streamId + } + } + + /** Déconnexion : efface config + cache M3U. */ + fun logout() { + XtreamApplication.instance.appContainer.m3uRepository.invalidateCache() + XtreamApplication.instance.appContainer.authRepository.logout() + } + + private fun refreshCategoryOrder() { + val current = _categories.value + if (current is UiState.Success) { + _categories.value = UiState.Success(sortCategories(current.data)) + } + } + + private fun refreshStreamOrder() { + val current = _streams.value + if (current is UiState.Success) { + _streams.value = UiState.Success(sortStreams(current.data)) + } + } + + private fun sortCategories(categories: List): List = + CategorySortUtils.sortLiveCategories(categories, usageRepo.liveCategoryCounts()) + + private fun sortStreams(streams: List): List = + UsageSortUtils.sortByUsage(streams, usageRepo.liveStreamCounts()) { it.streamId.toString() } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt new file mode 100644 index 0000000..81149e4 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/LoginViewModel.kt @@ -0,0 +1,107 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.repository.AuthRepository +import ma.tkgraph.xtreamtv.data.repository.UserInfo + +/** + * ViewModel pour l'écran de connexion / configuration. + * Gère la connexion Xtream et la configuration M3U. + */ +class LoginViewModel : ViewModel() { + + private val authRepository: AuthRepository = + XtreamApplication.instance.appContainer.authRepository + private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository + + private val _uiState = MutableStateFlow>(UiState.Idle) + val uiState: StateFlow> = _uiState.asStateFlow() + + /** Type de source (Compose-reactive). */ + var sourceType by mutableStateOf(SourceType.XTREAM) + var serverUrl: String = "" + private set + var username: String = "" + private set + var password: String = "" + private set + var m3uUrl: String = "" + private set + + init { + // Charger la config existante + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + if (config != null) { + sourceType = config.sourceType + serverUrl = config.serverUrl + username = config.username + password = config.password + m3uUrl = config.m3uUrl + } + } + + fun switchSourceType(type: SourceType) { + sourceType = type + } + + fun updateServerUrl(value: String) { + serverUrl = value.trim() + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updateUsername(value: String) { + username = value.trim() + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updatePassword(value: String) { + password = value.trim() + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + fun updateM3uUrl(value: String) { + m3uUrl = value.trim() + if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle + } + + /** Connexion au serveur Xtream. */ + fun login() { + if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return + viewModelScope.launch { + _uiState.value = UiState.Loading + val result = authRepository.login(serverUrl, username, password) + _uiState.value = result + } + } + + /** Sauvegarde la config M3U et invalide le cache. */ + fun saveM3uConfig() { + if (m3uUrl.isBlank()) return + m3uRepository.invalidateCache() + authRepository.saveM3uConfig(m3uUrl) + _uiState.value = UiState.Success(UserInfo(username = "M3U")) + } + + /** Déconnexion : efface la config et le cache M3U. */ + fun logout() { + m3uRepository.invalidateCache() + authRepository.logout() + serverUrl = "" + username = "" + password = "" + m3uUrl = "" + sourceType = SourceType.XTREAM + _uiState.value = UiState.Idle + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt new file mode 100644 index 0000000..c778f46 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt @@ -0,0 +1,301 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpisodeDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository +import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot +import ma.tkgraph.xtreamtv.util.UrlBuilder + +class MainMenuViewModel(application: Application) : AndroidViewModel(application) { + + private val container = XtreamApplication.instance.appContainer + private val progressRepository = PlaybackProgressRepository(application) + private val recentLiveRepository = container.recentLiveRepository + + private val _continueItems = MutableStateFlow>>(UiState.Loading) + val continueItems: StateFlow>> = _continueItems.asStateFlow() + + private val _recentLiveItems = MutableStateFlow>>(UiState.Success(emptyList())) + val recentLiveItems: StateFlow>> = _recentLiveItems.asStateFlow() + + init { + refreshRecentLive() + refreshContinueWatching() + } + + + fun refreshRecentLive() { + viewModelScope.launch(Dispatchers.IO) { + val items = recentLiveRepository.getRecentStreams() + .map { entry -> + RecentLiveItem( + id = "live:${entry.stream.streamId}", + title = entry.stream.name, + subtitle = "LIVE • Dernière utilisation", + imageUrl = entry.stream.streamIcon, + lastUsedMs = entry.lastUsedMs, + stream = entry.stream + ) + } + _recentLiveItems.value = UiState.Success(items) + } + } + + fun recordRecentLive(stream: LiveStreamDto) { + recentLiveRepository.record(stream) + refreshRecentLive() + } + + /** + * Version hybride V12 : + * - affichage instantané avec les métadonnées locales quand elles existent ; + * - hydratation legacy seulement pour les anciennes progressions sans métadonnées ; + * - sauvegarde de ces métadonnées pour que les ouvertures suivantes soient rapides. + */ + fun refreshContinueWatching() { + viewModelScope.launch(Dispatchers.IO) { + val allKeys = progressRepository.getInProgressKeys() + if (allKeys.isEmpty()) { + _continueItems.value = UiState.Success(emptyList()) + return@launch + } + + val metadataItems = allKeys + .mapNotNull { key -> buildContinueItemFromMetadata(key) } + .sortedByDescending { it.lastWatchedMs } + + val missingKeys = allKeys + .filter { key -> progressRepository.getContinueMetadata(key) == null } + .toSet() + + if (metadataItems.isNotEmpty()) { + _continueItems.value = UiState.Success(metadataItems.take(MAX_CONTINUE_ITEMS)) + } + + if (missingKeys.isEmpty()) { + if (metadataItems.isEmpty()) _continueItems.value = UiState.Success(emptyList()) + return@launch + } + + // Compatibilité avec les progressions créées avant la V11/V12. + // Cette phase peut être plus lente, mais elle ne sert qu'à reconstruire les métadonnées manquantes. + val legacyItems = mutableListOf() + legacyItems += loadVodContinueItems(missingKeys) + legacyItems += loadSeriesContinueItems(missingKeys) + + legacyItems.forEach { item -> + progressRepository.saveContinueMetadata( + key = item.progressKey, + stream = item.stream, + allStreams = emptyList(), + currentIndex = 0, + progressKeys = emptyList(), + playbackUrl = item.stream.directUrl.takeIf { it.isNotBlank() } + ) + } + + val merged = (metadataItems + legacyItems) + .distinctBy { it.id } + .sortedByDescending { it.lastWatchedMs } + .take(MAX_CONTINUE_ITEMS) + + _continueItems.value = UiState.Success(merged) + } + } + + private fun buildContinueItemFromMetadata(key: String): ContinueWatchingItem? { + val metadata = progressRepository.getContinueMetadata(key) ?: return null + val progress = progressRepository.getPlaybackProgress(key) + if (!progress.hasProgress) return null + val typeLabel = when { + key.startsWith("vod:") -> "Film" + key.startsWith("series:") -> "Série" + else -> "Vidéo" + } + val resolvedAllStreams = metadata.allStreams.takeIf { it.isNotEmpty() } ?: emptyList() + val resolvedIndex = metadata.currentIndex + .takeIf { it >= 0 && it < resolvedAllStreams.size } + ?: 0 + val stream = resolvedAllStreams.getOrNull(resolvedIndex) ?: metadata.stream + + return ContinueWatchingItem( + id = key, + title = metadata.title.ifBlank { stream.name }, + subtitle = "$typeLabel • ${progressText(progress)}", + imageUrl = metadata.imageUrl ?: stream.streamIcon, + progress = progress, + lastWatchedMs = progressRepository.getLastWatched(key), + stream = stream, + allStreams = resolvedAllStreams, + currentIndex = resolvedIndex, + progressKey = key, + progressKeys = metadata.progressKeys + ) + } + + private suspend fun loadVodContinueItems(allowedKeys: Set): List { + val vodKeys = allowedKeys.filter { it.startsWith("vod:") } + if (vodKeys.isEmpty()) return emptyList() + + val movies = when (val result = container.vodRepository.getMovies(null)) { + is UiState.Success -> result.data.associateBy { it.streamId } + else -> emptyMap() + } + val config = container.secureStorage.getCredentials() ?: return emptyList() + val urlBuilder = UrlBuilder(config) + + return vodKeys.mapNotNull { key -> + val streamId = key.removePrefix("vod:").toIntOrNull() ?: return@mapNotNull null + val movie = movies[streamId] ?: return@mapNotNull null + val progress = progressRepository.getPlaybackProgress(key) + if (!progress.hasProgress) return@mapNotNull null + ContinueWatchingItem( + id = key, + title = movie.name, + subtitle = "Film • ${progressText(progress)}", + imageUrl = movie.streamIcon, + progress = progress, + lastWatchedMs = progressRepository.getLastWatched(key), + stream = movie.toPlayerStream(urlBuilder), + allStreams = emptyList(), + currentIndex = 0, + progressKey = key, + progressKeys = emptyList() + ) + } + } + + private suspend fun loadSeriesContinueItems(allowedKeys: Set): List { + val seriesKeys = allowedKeys.filter { it.startsWith("series:") } + if (seriesKeys.isEmpty()) return emptyList() + + val keysBySeriesId = seriesKeys.mapNotNull { key -> + val parts = key.split(":") + val seriesId = parts.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null + seriesId to key + }.groupBy({ it.first }, { it.second }) + + val seriesById = when (val result = container.seriesRepository.getSeriesList(null)) { + is UiState.Success -> result.data.associateBy { it.seriesId } + else -> emptyMap() + } + val config = container.secureStorage.getCredentials() ?: return emptyList() + val urlBuilder = UrlBuilder(config) + + return keysBySeriesId.mapNotNull { (seriesId, keys) -> + val series = seriesById[seriesId] ?: return@mapNotNull null + val info = when (val result = container.seriesRepository.getSeriesInfo(seriesId)) { + is UiState.Success -> result.data + else -> return@mapNotNull null + } + val playableEpisodes = info.seasons.flatMap { season -> + info.episodes[season.seasonNumber.toString()].orEmpty() + } + if (playableEpisodes.isEmpty()) return@mapNotNull null + + val episodeStreams = playableEpisodes.map { episode -> episode.toPlayerStream(series, urlBuilder) } + val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" } + val bestKey = keys.maxByOrNull { progressRepository.getLastWatched(it) } ?: return@mapNotNull null + val index = progressKeys.indexOf(bestKey).takeIf { it >= 0 } ?: 0 + val episode = playableEpisodes.getOrNull(index) ?: return@mapNotNull null + val progress = progressRepository.getPlaybackProgress(bestKey) + if (!progress.hasProgress) return@mapNotNull null + + ContinueWatchingItem( + id = bestKey, + title = series.name, + subtitle = "Épisode ${episode.episodeNum} • ${episode.displayTitle().ifBlank { "Sans titre" }} • ${progressText(progress)}", + imageUrl = episode.info?.movieImage ?: series.cover, + progress = progress, + lastWatchedMs = progressRepository.getLastWatched(bestKey), + stream = episodeStreams[index], + allStreams = episodeStreams, + currentIndex = index, + progressKey = bestKey, + progressKeys = progressKeys + ) + } + } + + private fun VodDto.toPlayerStream(urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto( + name = name, + streamId = streamId, + streamIcon = streamIcon, + containerExtension = containerExtension, + directUrl = urlBuilder.vodUrl(streamId, containerExtension) + ) + + private fun EpisodeDto.toPlayerStream(series: SeriesDto, urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto( + name = episodePlayerTitle(series.name, this), + streamId = id, + streamIcon = info?.movieImage ?: series.cover, + containerExtension = containerExtension, + directUrl = urlBuilder.seriesUrl(id, containerExtension) + ) + + companion object { + private const val MAX_CONTINUE_ITEMS = 12 + } +} + +data class RecentLiveItem( + val id: String, + val title: String, + val subtitle: String, + val imageUrl: String?, + val lastUsedMs: Long, + val stream: LiveStreamDto +) + +data class ContinueWatchingItem( + val id: String, + val title: String, + val subtitle: String, + val imageUrl: String?, + val progress: PlaybackProgressSnapshot, + val lastWatchedMs: Long, + val stream: LiveStreamDto, + val allStreams: List, + val currentIndex: Int, + val progressKey: String, + val progressKeys: List +) + +private fun progressText(progress: PlaybackProgressSnapshot): String { + return if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs) +} + +private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: "" + +private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String { + val title = episode.displayTitle() + return if (title.isBlank()) { + "$seriesName - Ep ${episode.episodeNum}" + } else { + "$seriesName - Ep ${episode.episodeNum}: $title" + } +} + +private fun formatPlaybackTime(ms: Long): String { + val totalSeconds = (ms / 1000L).coerceAtLeast(0L) + val hours = totalSeconds / 3600L + val minutes = (totalSeconds % 3600L) / 60L + val seconds = totalSeconds % 60L + return if (hours > 0L) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt new file mode 100644 index 0000000..abdf191 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt @@ -0,0 +1,184 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.SourceType +import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository +import ma.tkgraph.xtreamtv.util.UrlBuilder +import timber.log.Timber + +data class PlayerState( + val stream: LiveStreamDto = LiveStreamDto(), + val allStreams: List = emptyList(), + val currentIndex: Int = 0, + val showOverlay: Boolean = true, + val resolution: String = "", + val currentProgram: String = "", + val nextProgram: String = "", + val restartTrigger: Int = 0, + val freezeRetries: Int = 0 +) + +class PlayerViewModel : ViewModel() { + + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + private val usageRepo = XtreamApplication.instance.appContainer.usageRepository + private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository + + private val _state = MutableStateFlow(PlayerState(showOverlay = true)) + val state: StateFlow = _state.asStateFlow() + + fun initialize(stream: LiveStreamDto, allStreams: List, index: Int) { + _state.value = PlayerState( + stream = stream, + allStreams = allStreams, + currentIndex = index, + showOverlay = true + ) + loadEpg(stream.streamId) + } + + /** + * Construit l'URL de lecture selon la source configurée. + * - Mode Xtream : construit l'URL via [UrlBuilder] + * - Mode M3U : utilise [LiveStreamDto.directUrl] directement + */ + fun buildUrl(stream: LiveStreamDto): String { + // En mode M3U, utiliser l'URL directe du stream + if (stream.directUrl.isNotBlank()) { + return stream.directUrl + } + // Mode Xtream : construction via UrlBuilder + val config = XtreamApplication.instance.appContainer.secureStorage.getConfig() + ?: return "" + if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) { + return stream.directUrl + } + return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension) + } + + fun updateResolution(resolution: String) { + _state.value = _state.value.copy(resolution = resolution) + } + + private fun loadEpg(streamId: Int) { + viewModelScope.launch { + val result = epgRepo.getShortEpg(streamId) + if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) { + val programs = result.data + if (programs.isNotEmpty()) { + val now = System.currentTimeMillis() / 1000 + val current = programs.firstOrNull { + val start = it.startTimestamp?.toLongOrNull() + val stop = it.stopTimestamp?.toLongOrNull() + start != null && start <= now && (stop == null || stop >= now) + } ?: programs.firstOrNull() + val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() } + val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1] + else null + _state.value = _state.value.copy( + currentProgram = current?.titleText() ?: "", + nextProgram = next?.titleText() ?: "" + ) + } + } + } + } + + fun zappingLeft() { + val current = _state.value + if (current.allStreams.isEmpty()) return + val newIndex = if (current.currentIndex > 0) current.currentIndex - 1 + else current.allStreams.lastIndex + val newStream = current.allStreams[newIndex] + recordLiveIfNeeded(newStream) + _state.value = PlayerState( + stream = newStream, + allStreams = current.allStreams, + currentIndex = newIndex, + showOverlay = true + ) + loadEpg(newStream.streamId) + } + + fun zappingRight() { + val current = _state.value + if (current.allStreams.isEmpty()) return + val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1 + else 0 + val newStream = current.allStreams[newIndex] + recordLiveIfNeeded(newStream) + _state.value = PlayerState( + stream = newStream, + allStreams = current.allStreams, + currentIndex = newIndex, + showOverlay = true + ) + loadEpg(newStream.streamId) + } + + + fun playNextItem(): Boolean { + val current = _state.value + if (current.allStreams.isEmpty() || current.currentIndex >= current.allStreams.lastIndex) return false + val newIndex = current.currentIndex + 1 + val newStream = current.allStreams[newIndex] + _state.value = current.copy( + stream = newStream, + currentIndex = newIndex, + showOverlay = true, + resolution = "", + currentProgram = "", + nextProgram = "" + ) + return true + } + + fun toggleOverlay() { + _state.value = _state.value.copy(showOverlay = !_state.value.showOverlay) + } + + fun hideOverlay() { + _state.value = _state.value.copy(showOverlay = false) + } + + fun showOverlay() { + _state.value = _state.value.copy(showOverlay = true) + } + + fun requestRestart() { + val s = _state.value + if (s.freezeRetries >= 3) return + val retries = s.freezeRetries + 1 + Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3") + _state.value = s.copy( + restartTrigger = s.restartTrigger + 1, + freezeRetries = retries + ) + } + + fun resetFreezeCounter() { + val s = _state.value + if (s.freezeRetries > 0) { + _state.value = s.copy(freezeRetries = 0) + } + } + + fun canRestart(): Boolean = _state.value.freezeRetries < 3 + + private fun recordLiveIfNeeded(stream: LiveStreamDto) { + if (!stream.streamType.equals("live", ignoreCase = true)) return + usageRepo.recordLiveStream(stream.streamId) + recentLiveRepo.record(stream) + } + + fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId) + fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt new file mode 100644 index 0000000..90a5431 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/ReplayViewModel.kt @@ -0,0 +1,57 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.EpgProgramDto +import ma.tkgraph.xtreamtv.data.model.LiveStreamDto +import ma.tkgraph.xtreamtv.data.model.UiState + +class ReplayViewModel : ViewModel() { + + private val epgRepo = XtreamApplication.instance.appContainer.epgRepository + + private val _epg = MutableStateFlow>>(UiState.Idle) + val epg: StateFlow>> = _epg.asStateFlow() + + private var currentStream: LiveStreamDto? = null + + fun loadReplay(stream: LiveStreamDto) { + currentStream = stream + viewModelScope.launch { + _epg.value = UiState.Loading + val result = epgRepo.getFullGuide(stream.streamId) + if (result is UiState.Success) { + // Filtrer : garder uniquement les programmes passés (déjà diffusés) + val nowSeconds = System.currentTimeMillis() / 1000 + val pastPrograms = result.data.filter { prog -> + val start = prog.startTimestamp?.toLongOrNull() ?: 0 + start > 0 && start <= nowSeconds + }.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 } + _epg.value = UiState.Success(pastPrograms) + } else { + _epg.value = result + } + } + } + + fun buildReplayUrl(program: EpgProgramDto): String? { + val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials() + ?: return null + val stream = currentStream ?: return null + + val durationMinutes = program.durationMinutes() + + return ma.tkgraph.xtreamtv.util.UrlBuilder(config) + .replayUrl( + streamId = stream.streamId, + start = program.start.orEmpty(), + durationMinutes = durationMinutes, + extension = "ts" + ) + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt new file mode 100644 index 0000000..a876b14 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/SeriesViewModel.kt @@ -0,0 +1,104 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesDto +import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils + +class SeriesViewModel : ViewModel() { + + private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + private val usageRepo = XtreamApplication.instance.appContainer.usageRepository + + private val _categories = MutableStateFlow>>(UiState.Success(emptyList())) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _series = MutableStateFlow>>(UiState.Success(emptyList())) + val series: StateFlow>> = _series.asStateFlow() + + private val _seriesInfo = MutableStateFlow>(UiState.Idle) + val seriesInfo: StateFlow> = _seriesInfo.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + init { + loadCategories() + loadSeries() + } + + fun loadCategories() { + viewModelScope.launch { + val r = seriesRepo.getCategories() + if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data)) + } + } + + fun loadSeries(categoryId: Int? = null) { + viewModelScope.launch { + val r = seriesRepo.getSeriesList(categoryId) + if (r is UiState.Success) _series.value = UiState.Success(sortSeries(r.data)) + } + } + + fun selectCategory(categoryId: Int?) { + usageRepo.recordSeriesCategory(categoryId) + _selectedCategoryId.value = categoryId + refreshCategoryOrder() + if (categoryId == -1) { + loadSeries(null) // Load all, filter in UI + } else { + loadSeries(categoryId) + } + } + + fun recordSeriesUsage(seriesId: Int) { + usageRepo.recordSeriesItem(seriesId) + refreshSeriesOrder() + } + + fun loadSeriesInfo(seriesId: Int) { + viewModelScope.launch { + val r = seriesRepo.getSeriesInfo(seriesId) + if (r is UiState.Success) _seriesInfo.value = r + } + } + + fun toggleFavorite(seriesId: Int) { + favRepo.toggleFavoriteSeries(seriesId) + _favoriteIds.value = favRepo.getFavoriteSeriesIds() + } + + private fun refreshCategoryOrder() { + val current = _categories.value + if (current is UiState.Success) { + _categories.value = UiState.Success(sortCategories(current.data)) + } + } + + private fun refreshSeriesOrder() { + val current = _series.value + if (current is UiState.Success) { + _series.value = UiState.Success(sortSeries(current.data)) + } + } + + private fun sortCategories(categories: List): List = + CategorySortUtils.sortSeriesCategories(categories, usageRepo.seriesCategoryCounts()) + + private fun sortSeries(series: List): List = + UsageSortUtils.sortByUsage(series, usageRepo.seriesItemCounts()) { it.seriesId.toString() } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt new file mode 100644 index 0000000..932306f --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/VodViewModel.kt @@ -0,0 +1,93 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ma.tkgraph.xtreamtv.XtreamApplication +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodDto +import ma.tkgraph.xtreamtv.util.CategorySortUtils +import ma.tkgraph.xtreamtv.util.UsageSortUtils + +class VodViewModel : ViewModel() { + + private val vodRepo = XtreamApplication.instance.appContainer.vodRepository + private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository + private val usageRepo = XtreamApplication.instance.appContainer.usageRepository + + private val _categories = MutableStateFlow>>(UiState.Success(emptyList())) + val categories: StateFlow>> = _categories.asStateFlow() + + private val _movies = MutableStateFlow>>(UiState.Success(emptyList())) + val movies: StateFlow>> = _movies.asStateFlow() + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + init { + loadCategories() + loadMovies() + } + + fun loadCategories() { + viewModelScope.launch { + val r = vodRepo.getCategories() + if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data)) + } + } + + fun loadMovies(categoryId: Int? = null) { + viewModelScope.launch { + val r = vodRepo.getMovies(categoryId) + if (r is UiState.Success) _movies.value = UiState.Success(sortMovies(r.data)) + } + } + + fun selectCategory(categoryId: Int?) { + usageRepo.recordVodCategory(categoryId) + _selectedCategoryId.value = categoryId + refreshCategoryOrder() + if (categoryId == -1) { + loadMovies(null) // Load all, filter in UI + } else { + loadMovies(categoryId) + } + } + + fun recordMovieUsage(streamId: Int) { + usageRepo.recordVodItem(streamId) + refreshMovieOrder() + } + + fun toggleFavorite(streamId: Int) { + favRepo.toggleFavoriteVod(streamId) + _favoriteIds.value = favRepo.getFavoriteVodIds() + } + + private fun refreshCategoryOrder() { + val current = _categories.value + if (current is UiState.Success) { + _categories.value = UiState.Success(sortCategories(current.data)) + } + } + + private fun refreshMovieOrder() { + val current = _movies.value + if (current is UiState.Success) { + _movies.value = UiState.Success(sortMovies(current.data)) + } + } + + private fun sortCategories(categories: List): List = + CategorySortUtils.sortVodCategories(categories, usageRepo.vodCategoryCounts()) + + private fun sortMovies(movies: List): List = + UsageSortUtils.sortByUsage(movies, usageRepo.vodItemCounts()) { it.streamId.toString() } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt new file mode 100644 index 0000000..89e5ae1 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt @@ -0,0 +1,53 @@ +package ma.tkgraph.xtreamtv.ui.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import ma.tkgraph.xtreamtv.data.repository.YoutubeRepository +import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem + +class YoutubeViewModel(application: Application) : AndroidViewModel(application) { + private val repository = YoutubeRepository(application) + + private val _videos = MutableStateFlow(repository.getVideos()) + val videos: StateFlow> = _videos.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + fun addVideo(title: String, input: String): Boolean { + val result = repository.addVideo(title, input) + return result.fold( + onSuccess = { + refresh() + _message.value = "Vidéo ajoutée" + true + }, + onFailure = { + _message.value = it.message ?: "Impossible d'ajouter cette vidéo" + false + } + ) + } + + fun deleteVideo(id: String) { + repository.deleteVideo(id) + refresh() + _message.value = "Vidéo supprimée" + } + + fun markPlayed(id: String) { + repository.markPlayed(id) + refresh() + } + + fun clearMessage() { + _message.value = null + } + + private fun refresh() { + _videos.value = repository.getVideos() + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt b/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt new file mode 100644 index 0000000..9ba8912 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt @@ -0,0 +1,101 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto +import ma.tkgraph.xtreamtv.data.model.VodCategoryDto + +/** + * Trie les catégories : + * 1. catégories spéciales éventuelles + * 2. catégories les plus utilisées + * 3. priorités historiques du projet + * 4. ordre alphabétique + */ +object CategorySortUtils { + + private val PRIORITY_KEYWORDS = listOf("bein", "beIN") + + private val FRENCH_KEYWORDS = listOf( + "france", "français", "francaise", "française", + "fr", "fra", "francais", "info", "infos" + ) + + private val ARABIC_KEYWORDS = listOf( + "arab", "arabic", "arabe", "arabia", + "arabique", "maghreb", "moyen-orient", + "عربي", "عربية", "العربية" + ) + + private val SPECIAL_NAMES = listOf( + "all channels", "toutes les chaînes", + "all movies", "tous les films", + "all series", "toutes les séries", + "favorites", "favoris", "★" + ) + + fun sortLiveCategories( + categories: List, + usageCounts: Map = emptyMap() + ): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + idSelector = { it.categoryId }, + usageCounts = usageCounts, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + fun sortVodCategories( + categories: List, + usageCounts: Map = emptyMap() + ): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + idSelector = { it.categoryId }, + usageCounts = usageCounts, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + fun sortSeriesCategories( + categories: List, + usageCounts: Map = emptyMap() + ): List { + return categories.sortedWith(categoryComparator( + nameSelector = { it.categoryName }, + idSelector = { it.categoryId }, + usageCounts = usageCounts, + isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") } + )) + } + + private fun categoryComparator( + nameSelector: (T) -> String, + idSelector: (T) -> String, + usageCounts: Map, + isSpecial: (String) -> Boolean + ): Comparator = Comparator { a, b -> + val nameA = nameSelector(a).lowercase().trim() + val nameB = nameSelector(b).lowercase().trim() + val usageA = usageCounts[idSelector(a)] ?: 0 + val usageB = usageCounts[idSelector(b)] ?: 0 + + when { + isSpecial(nameA) && !isSpecial(nameB) -> -1 + !isSpecial(nameA) && isSpecial(nameB) -> 1 + usageA != usageB -> usageB.compareTo(usageA) + isPriority(nameA) && !isPriority(nameB) -> -1 + !isPriority(nameA) && isPriority(nameB) -> 1 + isFrench(nameA) && !isFrench(nameB) -> -1 + !isFrench(nameA) && isFrench(nameB) -> 1 + isArabic(nameA) && !isArabic(nameB) -> -1 + !isArabic(nameA) && isArabic(nameB) -> 1 + else -> nameA.compareTo(nameB) + } + } + + private fun isPriority(name: String): Boolean = PRIORITY_KEYWORDS.any { name.contains(it) } + private fun isFrench(name: String): Boolean = FRENCH_KEYWORDS.any { name.contains(it) } + private fun isArabic(name: String): Boolean = + ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt b/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt new file mode 100644 index 0000000..d5acb59 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/util/SessionManager.kt @@ -0,0 +1,14 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.storage.SecureStorage + +/** + * Vérifie l'état de la session au démarrage. + */ +class SessionManager(private val storage: SecureStorage) { + + fun hasValidSession(): Boolean { + val config = storage.getConfig() ?: return false + return config.isValid + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt b/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt new file mode 100644 index 0000000..5be0070 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/util/UrlBuilder.kt @@ -0,0 +1,53 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.XtreamConfig + +class UrlBuilder(private val config: XtreamConfig) { + + fun liveUrl(streamId: Int, extension: String = "m3u8"): String = + "${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension" + + fun vodUrl(streamId: Int, extension: String = "mp4"): String = + "${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension" + + fun seriesUrl(episodeId: Int, extension: String = "mp4"): String = + "${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension" + + /** + * Construit l'URL timeshift pour le replay. + * Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts + * + * @param streamId ID de la chaîne + * @param start Date de début au format "2026-06-11 20:00" + * @param durationMinutes Durée en minutes (entre 1 et 720) + * @param extension Extension du fichier (ts par défaut) + */ + fun replayUrl( + streamId: Int, + start: String, + durationMinutes: Int = 120, + extension: String = "ts" + ): String { + val safeDuration = durationMinutes.coerceIn(1, 720) + val formattedStart = formatTimeshiftStart(start) + return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension" + } + + /** + * Formate la date pour le timeshift. + * "2026-06-11 20:00" → "2026-06-11:20-00" + */ + private fun formatTimeshiftStart(start: String): String { + val match = Regex("""(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})""").find(start.trim()) + if (match != null) { + val date = match.groupValues[1] + val hour = match.groupValues[2] + val minute = match.groupValues[3] + return "$date:$hour-$minute" + } + return start.trim().take(16).replace(" ", ":").replace(Regex(""":(\d{2})$"""), "-$1") + } + + private val baseUrl: String + get() = config.serverUrl.trimEnd('/') +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt b/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt new file mode 100644 index 0000000..44c77fe --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/util/UsageSortUtils.kt @@ -0,0 +1,21 @@ +package ma.tkgraph.xtreamtv.util + +/** + * Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur. + * L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement. + */ +object UsageSortUtils { + fun sortByUsage( + items: List, + usageCounts: Map, + idSelector: (T) -> String + ): List { + if (items.size < 2 || usageCounts.isEmpty()) return items + return items.withIndex() + .sortedWith( + compareByDescending> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 } + .thenBy { indexed -> indexed.index } + ) + .map { it.value } + } +} diff --git a/app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt b/app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt new file mode 100644 index 0000000..e3dfee7 --- /dev/null +++ b/app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt @@ -0,0 +1,165 @@ +package ma.tkgraph.xtreamtv.util + +import android.net.Uri + +/** + * Petites fonctions communes pour transformer un code/lien YouTube en cible lisible dans WebView. + * On accepte : code vidéo, youtube.com/watch?v=..., youtu.be/..., shorts, live et playlist. + */ +object YoutubeUrlUtils { + const val DEFAULT_WATCH_PREFIX = "https://www.youtube.com/watch?v=" + const val WEBVIEW_ORIGIN = "https://imedia-youtube.local/" + const val DESKTOP_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + private val youtubeVideoIdPattern = Regex("^[A-Za-z0-9_-]{8,20}$") + + fun normalize(raw: String): String { + val value = raw.trim() + if (value.isBlank() || value == DEFAULT_WATCH_PREFIX) return "" + + return when { + value.startsWith("http://") || value.startsWith("https://") -> value + value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value" + value.startsWith("watch?v=") -> "https://www.youtube.com/$value" + value.startsWith("v=") -> DEFAULT_WATCH_PREFIX + value.removePrefix("v=") + youtubeVideoIdPattern.matches(value) -> DEFAULT_WATCH_PREFIX + value + else -> "https://$value" + } + } + + fun parse(raw: String): YoutubeTarget? { + val normalized = normalize(raw) + if (normalized.isBlank()) return null + + return runCatching { + val uri = Uri.parse(normalized) + val host = uri.host.orEmpty().lowercase() + val pathSegments = uri.pathSegments + + val videoId = when { + host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull() + uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v") + pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1] + else -> null + }?.takeIf { it.length in 8..20 } + + val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() } + + YoutubeTarget( + normalizedUrl = normalized, + videoId = videoId, + playlistId = playlistId, + siteUrl = normalized + ) + }.getOrNull() + } + + fun youtubeHeaders(): Map = mapOf( + "Referer" to WEBVIEW_ORIGIN, + "Origin" to WEBVIEW_ORIGIN.trimEnd('/') + ) + + fun buildIframeHtml(target: YoutubeTarget): String { + val embedUrl = target.toEmbedUrl().escapeHtml() + return """ + + + + + + + + + + + + + + """.trimIndent() + } + + private fun String.escapeHtml(): String = + replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") +} + +data class YoutubeTarget( + val normalizedUrl: String, + val videoId: String?, + val playlistId: String?, + val siteUrl: String? +) { + val canUseEmbed: Boolean = videoId != null || playlistId != null + + fun toEmbedUrl(): String { + val origin = Uri.encode(YoutubeUrlUtils.WEBVIEW_ORIGIN.trimEnd('/')) + val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin" + return when { + videoId != null -> "https://www.youtube.com/embed/$videoId?$common" + playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${Uri.encode(playlistId)}&$common" + else -> normalizedUrl + } + } + + fun toWatchOrNormalizedUrl(): String = when { + videoId != null -> "https://www.youtube.com/watch?v=$videoId" + else -> siteUrl ?: normalizedUrl + } + + fun thumbnailUrl(): String? = videoId?.let { "https://img.youtube.com/vi/$it/hqdefault.jpg" } +} diff --git a/app/src/main/res/drawable/ic_banner.png b/app/src/main/res/drawable/ic_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..49482aeacde411888611a7e9f382c1b66e3b1b3e GIT binary patch literal 12707 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 B;VJ+C literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/launch_background.xml b/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..5e1fcfb --- /dev/null +++ b/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..45e2cf6c41add808f1fc817ab56294f75fa8a1a4 GIT binary patch literal 5154 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%0A~A=9{&z`+&%2 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 B;VJ+C literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0bfcc34c693c0b30f2a8052049dda2b1ca16c130 GIT binary patch literal 8093 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 p-JE4({tssT|4H_qveG*;vNdI$|LBdOLg=lX*e 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9db042ee02a4fe522700147fa43a48eed07f56f0 GIT binary patch literal 25193 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 q^7hn=_W!>e;s5MoIJc~Lq3}%H-K=|dIDM^ + iMedia TV+ + + + البث المباشر + الأفلام + المسلسلات + دليل التلفاز + الإعدادات + + + رابط الخادم + اسم المستخدم + كلمة المرور + اتصال + جارٍ الاتصال… + مثال: http://server:8080 + + + تعذر الاتصال بالخادم. تحقق من شبكتك. + الخادم لا يستجيب. حاول مرة أخرى لاحقاً. + بيانات الدخول غير صحيحة أو الخادم غير صالح. + البث غير متاح لهذه القناة. + خطأ في الخادم (%d). اتصل بمزود الخدمة. + لم يتم العثور على برنامج لهذه القناة. + فشل التحميل. + إعادة المحاولة + + + جميع القنوات + ★ المفضلة + البحث عن قنوات… + لم يتم العثور على قنوات + مثبتة + تثبيت هذه القناة + إلغاء التثبيت + إضافة إلى المفضلة + إزالة من المفضلة + + + قيد التشغيل + القناة التالية + القناة السابقة + البرنامج الحالي + التالي + إعادة + مباشر + + + الإعادة — %s + مشاهدة الإعادة + الإعادة غير متاحة + الأيام المتاحة + + + جميع الأفلام + البحث عن أفلام… + لم يتم العثور على أفلام + مشاهدة الفيلم + + + جميع المسلسلات + البحث عن مسلسلات… + لم يتم العثور على مسلسلات + الموسم %d + الحلقة %d + الحلقة %1$d • %2$s + + + هل تريد الخروج من iMedia TV+؟ + هل أنت متأكد من رغبتك في الخروج؟ + نعم + لا + جارٍ التحميل… + لا توجد بيانات متاحة + قطع الاتصال + الإصدار %s + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..228cf8f --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,78 @@ + + iMedia TV+ + + + Direct TV + Films + Séries + Guide TV + Paramètres + + + URL du serveur + Identifiant + Mot de passe + Connexion + Connexion… + ex: http://serveur:8080 + + + Impossible de se connecter au serveur. Vérifiez votre réseau. + Le serveur ne répond pas. Réessayez plus tard. + Identifiants incorrects ou serveur invalide. + Flux indisponible pour cette chaîne. + Erreur serveur (%d). Contactez votre fournisseur. + Aucun programme trouvé pour cette chaîne. + Échec du chargement. + Réessayer + + + Toutes les chaînes + ★ Favoris + Rechercher des chaînes… + Aucune chaîne trouvée + Épinglée + Épingler cette chaîne + Désépingler + Ajouter aux favoris + Retirer des favoris + + + En cours + Chaîne suivante + Chaîne précédente + Programme en cours + Suivant + Replay + Direct + + + Replay — %s + Revoir + Replay non disponible + Jours disponibles + + + Tous les films + Rechercher des films… + Aucun film trouvé + Regarder le film + + + Toutes les séries + Rechercher des séries… + Aucune série trouvée + Saison %d + Épisode %d + Épisode %1$d • %2$s + + + Quitter iMedia TV+ ? + Êtes-vous sûr de vouloir quitter ? + Oui + Non + Chargement… + Aucune donnée disponible + Déconnexion + Version %s + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..430f745 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,19 @@ + + + #FFE50914 + #FFB30000 + #FFFFFFFF + #FF4A1C1C + #FF1A73E8 + #FFFFFFFF + #FF1A1A1A + #FF252525 + #FFFFFFFF + #FF0A0A0A + #FFEEEEEE + #FFFFD700 + #FFFF5252 + #FF4CAF50 + #FF00BCD4 + #FF333333 + diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml new file mode 100644 index 0000000..9fb09ba --- /dev/null +++ b/app/src/main/res/values/config.xml @@ -0,0 +1,9 @@ + + + 3000 + 3000 + 15000 + 30000 + 2 + 15 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..42fbad2 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,78 @@ + + iMedia TV+ + + + Live TV + Movies + Series + TV Guide + Settings + + + Server URL + Username + Password + Connect + Connecting… + e.g. http://server:8080 + + + Cannot reach server. Check your network. + Server is not responding. Try again later. + Invalid credentials or server. + Stream unavailable for this channel. + Server error (%d). Contact your provider. + No program found for this channel. + Failed to load content. + Retry + + + All Channels + ★ Favorites + Search channels… + No channels found + Pinned + Pin this channel + Unpin + Add to favorites + Remove from favorites + + + Now playing + Next channel + Previous channel + Current program + Next + Replay + Live + + + Replay — %s + Watch replay + No replay available + Available days + + + All Movies + Search movies… + No movies found + Watch movie + + + All Series + Search series… + No series found + Season %d + Episode %d + Episode %1$d • %2$s + + + Exit iMedia TV+? + Are you sure you want to exit? + Yes + No + Loading… + No data available + Disconnect + Version %s + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d390a40 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..aef732c --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt b/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt new file mode 100644 index 0000000..2058c04 --- /dev/null +++ b/app/src/test/java/ma/tkgraph/xtreamtv/data/repository/AuthRepositoryTest.kt @@ -0,0 +1,74 @@ +package ma.tkgraph.xtreamtv.data.repository + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import ma.tkgraph.xtreamtv.data.model.AuthResponse +import ma.tkgraph.xtreamtv.data.model.UiState +import ma.tkgraph.xtreamtv.data.model.UserInfo +import ma.tkgraph.xtreamtv.data.network.XtreamApi +import ma.tkgraph.xtreamtv.data.storage.SecureStorage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AuthRepositoryTest { + + private val api: XtreamApi = mockk() + private val storage: SecureStorage = mockk() + private lateinit var repo: AuthRepository + + @BeforeEach + fun setup() { + repo = AuthRepository(api, storage) + } + + @Test + fun `login with valid credentials returns Success`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + userInfo = UserInfo(username = "testuser"), + auth = "1", + status = "Active" + ) + + // When + val result = repo.login("http://server:8080", "testuser", "pass123") + + // Then + assertTrue(result is UiState.Success) + assertEquals("testuser", (result as UiState.Success).data.username) + } + + @Test + fun `login with invalid auth returns Error`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + auth = "0", + status = "Active" + ) + + // When + val result = repo.login("http://server:8080", "baduser", "badpass") + + // Then + assertTrue(result is UiState.Error) + } + + @Test + fun `login with expired account returns Error`() = runTest { + // Given + coEvery { api.authenticate(any(), any()) } returns AuthResponse( + auth = "1", + status = "Expired" + ) + + // When + val result = repo.login("http://server:8080", "user", "pass") + + // Then + assertTrue(result is UiState.Error) + assertTrue((result as UiState.Error).message.contains("expired", ignoreCase = true)) + } +} diff --git a/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt b/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt new file mode 100644 index 0000000..799bde6 --- /dev/null +++ b/app/src/test/java/ma/tkgraph/xtreamtv/util/CategorySortUtilsTest.kt @@ -0,0 +1,50 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CategorySortUtilsTest { + + @Test + fun `sortLiveCategories puts FR first, AR second, others last`() { + // Given + val categories = listOf( + LiveCategoryDto(categoryId = "1", categoryName = "Sports"), + LiveCategoryDto(categoryId = "2", categoryName = "France Info"), + LiveCategoryDto(categoryId = "3", categoryName = "العربية"), + LiveCategoryDto(categoryId = "4", categoryName = "French Cinema"), + LiveCategoryDto(categoryId = "5", categoryName = "News") + ) + + // When + val sorted = CategorySortUtils.sortLiveCategories(categories) + + // Then + assertEquals("France Info", sorted[0].categoryName) + assertEquals("French Cinema", sorted[1].categoryName) + assertEquals("العربية", sorted[2].categoryName) + assertEquals("News", sorted[3].categoryName) + assertEquals("Sports", sorted[4].categoryName) + } + + @Test + fun `special categories always come first`() { + // Given + val categories = listOf( + LiveCategoryDto(categoryId = "1", categoryName = "News"), + LiveCategoryDto(categoryId = "2", categoryName = "★ Favorites"), + LiveCategoryDto(categoryId = "3", categoryName = "All Channels"), + LiveCategoryDto(categoryId = "4", categoryName = "France Info") + ) + + // When + val sorted = CategorySortUtils.sortLiveCategories(categories) + + // Then + assertEquals("All Channels", sorted[0].categoryName) + assertEquals("★ Favorites", sorted[1].categoryName) + assertEquals("France Info", sorted[2].categoryName) + assertEquals("News", sorted[3].categoryName) + } +} diff --git a/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt b/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt new file mode 100644 index 0000000..ee82bf3 --- /dev/null +++ b/app/src/test/java/ma/tkgraph/xtreamtv/util/UrlBuilderTest.kt @@ -0,0 +1,50 @@ +package ma.tkgraph.xtreamtv.util + +import ma.tkgraph.xtreamtv.data.model.XtreamConfig +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UrlBuilderTest { + + private lateinit var builder: UrlBuilder + private val config = XtreamConfig("http://server:8080", "user", "pass") + + @BeforeEach + fun setup() { + builder = UrlBuilder(config) + } + + @Test + fun `liveUrl builds correct URL`() { + val url = builder.liveUrl(42) + assertEquals("http://server:8080/live/user/pass/42.m3u8", url) + } + + @Test + fun `liveUrl with custom extension`() { + val url = builder.liveUrl(42, "ts") + assertEquals("http://server:8080/live/user/pass/42.ts", url) + } + + @Test + fun `vodUrl builds correct URL`() { + val url = builder.vodUrl(123, "mp4") + assertEquals("http://server:8080/movie/user/pass/123.mp4", url) + } + + @Test + fun `seriesUrl builds correct URL`() { + val url = builder.seriesUrl(456, "mkv") + assertEquals("http://server:8080/series/user/pass/456.mkv", url) + } + + @Test + fun `replayUrl builds correct URL`() { + val url = builder.replayUrl(3600, "2026-06-10:12-00", 42) + assertEquals( + "http://server:8080/timeshift/user/pass/3600/2026-06-10:12-00/42.ts", + url + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2821102 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file for iMedia TV+ (Hermes Edition) +plugins { + id("com.android.application") version "8.8.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} diff --git a/docs/ERROR-HANDLING.md b/docs/ERROR-HANDLING.md new file mode 100644 index 0000000..7d3abe7 --- /dev/null +++ b/docs/ERROR-HANDLING.md @@ -0,0 +1,153 @@ +# Gestion d'erreurs — iMedia TV+ + +## Architecture + +```mermaid +flowchart TB + API[Serveur Xtream] -->|Timeout / 4xx / 5xx| Interceptor[OkHttp ErrorInterceptor] + Interceptor -->|Exception typée| Repository + Repository -->|Result / UiState.Error| ViewModel + ViewModel -->|Message localisé| UI[Écran Compose] + UI -->|Snackbar / Toast / Overlay| User[Utilisateur] +``` + +## Hiérarchie d'erreurs + +```kotlin +/** + * Toutes les erreurs Xtream sont des sealed classes + * pour un `when` exhaustif dans l'UI. + */ +sealed class XtreamException(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkException(message: String) : XtreamException(message) + class TimeoutException(message: String) : XtreamException(message) + class AuthException(message: String) : XtreamException(message) + class ServerException(val code: Int, message: String) : XtreamException(message) + class ParseException(message: String, cause: Throwable) : XtreamException(message, cause) + class StreamUnavailableException(message: String) : XtreamException(message) +} +``` + +## Table des erreurs + +| N° | Condition | Exception | Code UI | Message FR | Message EN | +|----|-----------|-----------|---------|------------|------------| +| 1 | Serveur injoignable | `NetworkException` | Erreur réseau | "Impossible de se connecter au serveur. Vérifiez votre réseau." | "Cannot reach server. Check your network." | +| 2 | Timeout dépassé (15s) | `TimeoutException` | Erreur réseau | "Le serveur ne répond pas. Réessayez plus tard." | "Server is not responding. Try again later." | +| 3 | Auth invalide | `AuthException` | Login bloqué | "Identifiants incorrects ou serveur invalide." | "Invalid credentials or server." | +| 4 | Flux vidéo cassé | `StreamUnavailableException` | Overlay erreur | "Flux indisponible pour cette chaîne." | "Stream unavailable for this channel." | +| 5 | HTTP 4xx | `ServerException` | Toast | "Erreur serveur (code X). Contactez votre fournisseur." | "Server error (code X). Contact your provider." | +| 6 | HTTP 5xx | `ServerException` | Toast | "Le serveur Xtream rencontre un problème." | "Xtream server is having issues." | +| 7 | JSON mal formé | `ParseException` | Fallback silencieux | "Données reçues incorrectes." | "Received data is malformed." | +| 8 | EPG vide | N/A (pas une erreur) | Texte vide | "Aucun programme trouvé pour cette chaîne." | "No program found for this channel." | +| 9 | Cache corrompu | N/A | Suppression + reload | "Mise à jour des données..." | "Updating data..." | + +## État loading global + +```kotlin +/** + * État unique pour TOUS les écrans. + * Permet un traitement uniforme dans les composants UI. + */ +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error( + val message: String, // Message affiché à l'utilisateur + val retry: Boolean = true, // Afficher un bouton "Réessayer" ? + val exception: XtreamException? = null // Pour logging uniquement + ) : UiState +} +``` + +## Intercepteur OkHttp + +```kotlin +class XtreamErrorInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return try { + val response = chain.proceed(request) + if (!response.isSuccessful) { + when (response.code) { + 401, 403 -> throw XtreamException.AuthException( + "Authentication failed" + ) + in 400..499 -> throw XtreamException.ServerException( + response.code, "Client error" + ) + in 500..599 -> throw XtreamException.ServerException( + response.code, "Server error" + ) + } + } + response + } catch (e: SocketTimeoutException) { + throw XtreamException.TimeoutException("Connection timed out") + } catch (e: ConnectException) { + throw XtreamException.NetworkException("Cannot connect") + } catch (e: UnknownHostException) { + throw XtreamException.NetworkException("DNS resolution failed") + } + } +} +``` + +## Comportement UI par erreur + +### Écran de connexion +``` +[Champs vides] +❌ Bouton Connexion désactivé + +[Erreur réseau] +✅ Bouton actif, message rouge sous le formulaire +✅ Champs conservés (pas de reset) + +[Auth refusée] +✅ Message : "Identifiants incorrects" +✅ Focus revient sur le champ mot de passe +``` + +### Écran Live TV +``` +[Erreur chargement chaînes] +✅ Toast 3s : "Impossible de charger les chaînes" +✅ Cache affiché si disponible + +[Chaîne sans flux] +✅ Overlay : "Flux indisponible" + bouton "Chaîne suivante" +``` + +### Lecteur vidéo +``` +[Flux mort pendant lecture] +✅ Remplacer par écran noir + message +✅ Bouton "Chaîne suivante" visible +✅ Retour possible vers le menu +``` + +## Règles de logging + +| Niveau | Usage | Exemple | +|--------|-------|---------| +| `Timber.d` | Debug uniquement (pas en prod) | "Loading 42 live channels" | +| `Timber.i` | Événements importants | "User logged in: user123" | +| `Timber.w` | Anomalies non bloquantes | "EPG cache expired for stream 99" | +| `Timber.e` | Erreurs utilisateur | "Stream unavailable for stream 42" | + +**❌ Interdit** : `Log.d("PASSWORD", password)` ou tout log contenant des credentials. + +## Annexes + +### Codes Xtream que l'on peut rencontrer + +| Code | Signification | Action | +|------|--------------|--------| +| `auth = "0"` | Compte invalide | Message login | +| `status = "Expired"` | Abonnement expiré | Message distinct | +| `status = "Disabled"` | Compte désactivé | Message distinct | +| `tv_archive = 0` | Pas d'archive | Cacher bouton Replay | +| `has_archive = false` | Pas d'archive | Cacher bouton Replay | +| Réponse vide | Serveur HS ou mauvaise URL | Erreur réseau générique | diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..991bac1 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,159 @@ +# Sécurité — iMedia TV+ + +## Principes + +1. **Aucun secret en clair sur le disque ou dans les logs** +2. **Chiffrement de bout en bout** du stockage local des credentials +3. **Isolation** : l'application ne partage pas les credentials avec d'autres apps +4. **Principle of least privilege** : permissions Android minimales + +## Stockage des credentials + +### Implémentation + +```kotlin +// data/storage/SecureStorage.kt +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class SecureStorage(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "imedia_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveCredentials(server: String, username: String, password: String) { + prefs.edit() + .putString("server_url", server) + .putString("username", username) + .putString("password", password) + .apply() + } + + fun getCredentials(): Credentials? { + val server = prefs.getString("server_url", null) + val username = prefs.getString("username", null) + val password = prefs.getString("password", null) + return if (server != null && username != null && password != null) { + Credentials(server, username, password) + } else null + } + + fun clearCredentials() { + prefs.edit().clear().apply() + } +} +``` + +Pour les favoris et la chaîne épinglée (données non sensibles), la version 1 utilisait des `SharedPreferences` classiques — acceptable. Si on veut tout unifier, on utilise `EncryptedSharedPreferences` partout. + +### Ce qui est chiffré + +| Donnée | Niveau | Sensibilité | +|--------|--------|-------------| +| Server URL | AES-256 GCM | Haute | +| Username | AES-256 GCM | Haute | +| Password | AES-256 GCM | Critique | +| Favoris | AES-256 SIV keys | Moyenne | +| Chaîne épinglée | AES-256 SIV keys | Basse | + +## Fichier de mapping des endpoints + +**Important :** les URLs Xtream construites avec le builder ne doivent pas non plus être logguées. + +```kotlin +// util/XtreamUrlBuilder.kt +class XtreamUrlBuilder(private val credentials: Credentials) { + + fun liveUrl(streamId: Int): String = + "${credentials.server}/live/${credentials.username}/${credentials.password}/$streamId.m3u8" + + // ⚠️ Attention : cette méthode est appelée par PlayerScreen + // — NE PAS logger son résultat +} +``` + +## Règles strictes + +### ✅ À faire + +- Utiliser `Timber.d` pour les logs de debug (supprimés en release par ProGuard) +- Vérifier les inputs utilisateur avant de les envoyer (URL bien formée) +- Timeout réseau configuré (15s connect, 30s read) +- Nettoyer les credentials à la déconnexion +- Minify + ProGuard en release (obfuscation) + +### ❌ Interdit + +```kotlin +// JAMAIS : +Log.d("Auth", "Login with $username / $password") +Timber.d("Server URL: $serverUrl") +println("Password is: $password") +Log.e("API", "Stream URL: ${urlBuilder.liveUrl(42)}") + +// En release, ces logs ne devraient pas survivre, +// mais ne prenez pas le risque en debug non plus. +``` + +## ProGuard + +```pro +# app/proguard-rules.pro +# Sécurité : ne pas exposer les données chiffrées +-keepclassmembers class ma.tkgraph.xtreamtv.data.storage.** { *; } + +# OkHttp / Retrofit déjà couverts par leurs propres rules +# Ne pas garder de logs en release +-assumenosideeffects class timber.log.Timber { + public static *** d(...); + public static *** v(...); +} +``` + +## Permissions Android + +```xml + + + + +``` + +L'application ne lit pas de fichiers locaux et ne sauvegarde que des données dans son propre espace de stockage privé — pas besoin de permissions de stockage. + +## HTTPS + +Bien que le protocole Xtream utilise souvent HTTP (non chiffré), l'application doit : +1. **Accepter HTTP** pour la compatibilité +2. **Préférer HTTPS** si le serveur le supporte +3. **Ne pas désactiver la validation** SSL/TLS (pas de `TrustAllCertificates`) + +Si le fournisseur Xtream le permet, les credentials transitent en HTTPS, ce qui protège le mot de passe en transit. + +## Audit + +Une amélioration future possible : `Sentry` ou `Firebase Crashlytics` pour monitorer les crashs. Attention cependant à ne pas envoyer de données sensibles dans les rapports : + +```kotlin +// Dans Application.onCreate() +FirebaseCrashlytics.getInstance().setCustomKey("screen", "login") +// ❌ PAS FirebaseCrashlytics.getInstance().setCustomKey("username", username) +``` + +## Checklist de sécurité avant release + +- [ ] Aucun log de credentials dans le code (grep "password" dans les fichiers source) +- [ ] EncryptedSharedPreferences en place, pas de fallback SharedPreferences +- [ ] Timeouts réseau configurés +- [ ] Permissions minimales (seulement INTERNET + ACCESS_NETWORK_STATE) +- [ ] ProGuard activé en release +- [ ] Pas de validation SSL désactivée +- [ ] Gestion de la déconnexion efface TOUTES les données diff --git a/env.md b/env.md new file mode 100644 index 0000000..1ac84a2 --- /dev/null +++ b/env.md @@ -0,0 +1,41 @@ +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +$env:Path = "$env:JAVA_HOME\bin;$env:Path" + +java -version + +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk" +$env:Path = "$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:ANDROID_HOME\emulator;$env:Path" + +adb version +adb devices + +$env:Path = "D:\flutter\bin;$env:Path" + +flutter --version +flutter doctor + +Get-ChildItem -Path "C:\","D:\" -Filter flutter.bat -Recurse -ErrorAction SilentlyContinue + +ajoute le chemin trouvé dans : $env:Path = "D:\quelquechose\flutter\bin;$env:Path" + +Set-Content -Path ".\local.properties" -Value "sdk.dir=$($env:LOCALAPPDATA.Replace('\','/'))/Android/Sdk" + +Get-Content .\local.properties + +$old = "D:\07-Logiciels\12-IPTV Andoid TV HERMES" +$new = "D:\07-Logiciels\18-ClavierAndoMiBox" + +Copy-Item "$old\gradlew.bat" "$new\" -Force +Copy-Item "$old\gradlew" "$new\" -Force -ErrorAction SilentlyContinue +Copy-Item "$old\gradle" "$new\gradle" -Recurse -Force + +adb devices + +adb connect 192.168.1.8:5555 +adb devices + +adb -s 192.168.1.8:5555 install -r .\tv_keyboard\build\outputs\apk\debug\tv_keyboard-debug.apk + +adb -s f10778d8 install -r .\phone_remote\build\outputs\apk\debug\phone_remote-debug.apk + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5e845e5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 -XX:+UseG1GC +org.gradle.parallel=false +org.gradle.configuration-cache=false +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e11132 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/init_iptv_env.ps1 b/init_iptv_env.ps1 new file mode 100644 index 0000000..19cc0db --- /dev/null +++ b/init_iptv_env.ps1 @@ -0,0 +1,72 @@ +# init_iptv_env.ps1 +# Initialise l'environnement Android / ADB / Flutter pour le projet IPTV. +# À lancer depuis le dossier du projet : +# D:\07-Logiciels\12-IPTV Andoid TV HERMES + +$ErrorActionPreference = "Stop" + +function Add-ToPathIfExists { + param([string]$PathToAdd) + + if ([string]::IsNullOrWhiteSpace($PathToAdd)) { + return + } + + if (Test-Path $PathToAdd) { + $parts = $env:Path -split ';' + if ($parts -notcontains $PathToAdd) { + $env:Path = "$PathToAdd;$env:Path" + } + Write-Host "PATH OK: $PathToAdd" -ForegroundColor Green + } else { + Write-Host "Introuvable, ignoré: $PathToAdd" -ForegroundColor Yellow + } +} + +Write-Host "=== Initialisation environnement IPTV Android TV ===" -ForegroundColor Cyan + +# Java fourni par Android Studio +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +Add-ToPathIfExists "$env:JAVA_HOME\bin" + +# Android SDK +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk" + +Add-ToPathIfExists "$env:ANDROID_HOME\platform-tools" +Add-ToPathIfExists "$env:ANDROID_HOME\cmdline-tools\latest\bin" +Add-ToPathIfExists "$env:ANDROID_HOME\emulator" + +# Flutter +$flutterDefault = "D:\flutter\bin" +if (Test-Path $flutterDefault) { + Add-ToPathIfExists $flutterDefault +} else { + Write-Host "Flutter non trouvé dans D:\flutter\bin. Recherche rapide..." -ForegroundColor Yellow + $flutterBat = Get-ChildItem -Path "C:\","D:\" -Filter flutter.bat -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($flutterBat) { + Add-ToPathIfExists $flutterBat.Directory.FullName + } else { + Write-Host "flutter.bat non trouvé. Ce n'est pas bloquant pour builder l'app Android native." -ForegroundColor Yellow + } +} + +# local.properties pour Gradle Android +if (Test-Path ".\app") { + Set-Content -Path ".\local.properties" -Value "sdk.dir=$($env:LOCALAPPDATA.Replace('\','/'))/Android/Sdk" + Write-Host "local.properties écrit :" -ForegroundColor Green + Get-Content ".\local.properties" +} else { + Write-Host "Attention: ce dossier ne ressemble pas à la racine du projet Android. Place-toi dans D:\07-Logiciels\12-IPTV Andoid TV HERMES" -ForegroundColor Yellow +} + +Write-Host "`n=== Vérifications ===" -ForegroundColor Cyan + +try { java -version } catch { Write-Host "java introuvable" -ForegroundColor Red } +try { adb version } catch { Write-Host "adb introuvable" -ForegroundColor Red } +try { flutter --version } catch { Write-Host "flutter introuvable ou non nécessaire" -ForegroundColor Yellow } + +Write-Host "`nADB devices :" -ForegroundColor Cyan +adb devices + +Write-Host "`nEnvironnement initialisé pour cette fenêtre PowerShell." -ForegroundColor Green diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6cfbb17 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "iMediaTVPlus" +include(":app")