From 5ffaf9a92494f37a9819a933ca7252a7de938eeb Mon Sep 17 00:00:00 2001 From: nk Date: Thu, 4 Jun 2026 12:36:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(player):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D0=B7=D0=B2=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перепроверены все 594 рабочие станции на наличие битрейт-вариантов потока (скрипт-пробер). У 71 станции найдено по 2–4 качества (Record-флагманы 96/64/32, zaycev 256/128/48, ВГТРК 192/128/64, НАШЕ/Орфей/Шансон HQ и др.) — записаны в поле qualities в stations.json. HLS (EMG) и Love (UID-привязка) корректно пропущены. Клиент: модель StreamQuality, хранение в Room (миграция v5), предпочтение битрейта в настройках. На экране плеера — чип текущего качества (виден только если вариантов ≥2) и шторка «Качество звука» со ступенями; переключение на лету без сброса now-playing, выбор запоминается между станциями. Co-Authored-By: Claude Opus 4.8 --- app/src/main/assets/stations.json | 1286 ++++++++++++++++- .../com/radiola/data/local/AppDatabase.kt | 8 +- .../data/local/LocalStationDataSource.kt | 5 +- .../radiola/data/local/dto/LocalStationDto.kt | 10 +- .../data/local/entity/StationEntity.kt | 4 +- .../data/repository/SettingsRepositoryImpl.kt | 4 + .../data/repository/StationRepositoryImpl.kt | 22 +- app/src/main/java/com/radiola/di/AppModule.kt | 3 +- .../java/com/radiola/domain/model/Station.kt | 21 +- .../domain/repository/SettingsRepository.kt | 3 + .../com/radiola/service/PlayerController.kt | 12 + .../radiola/ui/player/PlayerBottomSheet.kt | 144 +- .../com/radiola/ui/player/PlayerViewModel.kt | 40 +- 13 files changed, 1473 insertions(+), 89 deletions(-) diff --git a/app/src/main/assets/stations.json b/app/src/main/assets/stations.json index c3300c9..6075407 100644 --- a/app/src/main/assets/stations.json +++ b/app/src/main/assets/stations.json @@ -13,7 +13,24 @@ "bgColor": "#1A2400", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://radiorecord.hostingradio.ru/rr_main96.aacp", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://radiorecord.hostingradio.ru/rr_main64.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://radiorecord.hostingradio.ru/rr_main32.aacp", + "type": "aac" + } + ] }, { "id": 2, @@ -148,7 +165,24 @@ "bgColor": "#280B0D", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://radiorecord.hostingradio.ru/rus96.aacp", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://radiorecord.hostingradio.ru/rus64.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://radiorecord.hostingradio.ru/rus32.aacp", + "type": "aac" + } + ] }, { "id": 11, @@ -178,7 +212,24 @@ "bgColor": "#FFFF17", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://radiorecord.hostingradio.ru/sd9096.aacp", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://radiorecord.hostingradio.ru/sd9064.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://radiorecord.hostingradio.ru/sd9032.aacp", + "type": "aac" + } + ] }, { "id": 13, @@ -1768,7 +1819,19 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://dfm.hostingradio.ru/dfm96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://dfm.hostingradio.ru/dfm32.aacp", + "type": "aac" + } + ] }, { "id": 26, @@ -2938,7 +3001,19 @@ "bgColor": "#C63032", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/nashe-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/nashe-64.mp3", + "type": "mp3" + } + ] }, { "id": 235, @@ -2953,7 +3028,19 @@ "bgColor": "#C63032", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/nashe20-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/nashe20-64.mp3", + "type": "mp3" + } + ] }, { "id": 236, @@ -3043,7 +3130,19 @@ "bgColor": "#302D2C", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://maximum.hostingradio.ru/maximum96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://maximum.hostingradio.ru/maximum32.aacp", + "type": "aac" + } + ] }, { "id": 246, @@ -3418,7 +3517,19 @@ "bgColor": "#69154A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://montecarlo.hostingradio.ru/montecarlo96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://montecarlo.hostingradio.ru/montecarlo32.aacp", + "type": "aac" + } + ] }, { "id": 251, @@ -3733,7 +3844,19 @@ "bgColor": "#FEFDFE", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://hitfm.hostingradio.ru/hitfm96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://hitfm.hostingradio.ru/hitfm32.aacp", + "type": "aac" + } + ] }, { "id": 264, @@ -4123,7 +4246,19 @@ "bgColor": "#FFFFFF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://rusradio.hostingradio.ru/rusradio96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://rusradio.hostingradio.ru/rusradio32.aacp", + "type": "aac" + } + ] }, { "id": 286, @@ -4573,7 +4708,29 @@ "bgColor": "#D7622B", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://radio7.hostingradio.ru:8040/radio7256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://radio7.hostingradio.ru:8040/radio7128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://radio7.hostingradio.ru:8040/radio764.mp3", + "type": "mp3" + }, + { + "bitrate": 32, + "url": "http://radio7.hostingradio.ru:8040/radio732.mp3", + "type": "mp3" + } + ] }, { "id": 369, @@ -4588,7 +4745,29 @@ "bgColor": "#D7622B", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://radio7.hostingradio.ru:8040/radio7256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://radio7.hostingradio.ru:8040/radio7128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://radio7.hostingradio.ru:8040/radio764.mp3", + "type": "mp3" + }, + { + "bitrate": 32, + "url": "http://radio7.hostingradio.ru:8040/radio732.mp3", + "type": "mp3" + } + ] }, { "id": 370, @@ -4723,7 +4902,24 @@ "bgColor": "#CC8033", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/pop256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/pop128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/pop48k", + "type": "aac" + } + ] }, { "id": 389, @@ -4738,7 +4934,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/disco256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/disco128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/disco48k", + "type": "aac" + } + ] }, { "id": 390, @@ -4753,7 +4966,24 @@ "bgColor": "#4D8080", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/club256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/club128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/club48k", + "type": "aac" + } + ] }, { "id": 391, @@ -4768,7 +4998,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/rock256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/rock128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/rock48k", + "type": "aac" + } + ] }, { "id": 392, @@ -4783,7 +5030,24 @@ "bgColor": "#661A33", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/rnb256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/rnb128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/rnb48k", + "type": "aac" + } + ] }, { "id": 393, @@ -4813,7 +5077,24 @@ "bgColor": "#CC3333", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/rus256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/rus128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/rus48k", + "type": "aac" + } + ] }, { "id": 395, @@ -4828,7 +5109,24 @@ "bgColor": "#B3CCFF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/relax256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/relax128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/relax48k", + "type": "aac" + } + ] }, { "id": 396, @@ -4843,7 +5141,24 @@ "bgColor": "#E6B3E6", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/zaychata256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/zaychata128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/zaychata48k", + "type": "aac" + } + ] }, { "id": 397, @@ -4858,7 +5173,24 @@ "bgColor": "#9980E6", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/kpop256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/kpop128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/kpop48k", + "type": "aac" + } + ] }, { "id": 406, @@ -4873,7 +5205,24 @@ "bgColor": "#669999", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/rap256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/rap128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/rap48k", + "type": "aac" + } + ] }, { "id": 398, @@ -4888,7 +5237,24 @@ "bgColor": "#334DB3", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/metal256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/metal128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/metal48k", + "type": "aac" + } + ] }, { "id": 399, @@ -4918,7 +5284,24 @@ "bgColor": "#99B3FF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/bass256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/bass128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/bass48k", + "type": "aac" + } + ] }, { "id": 401, @@ -4933,7 +5316,24 @@ "bgColor": "#CC99CC", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/love256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/love128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/love48k", + "type": "aac" + } + ] }, { "id": 402, @@ -4963,7 +5363,24 @@ "bgColor": "#80B3B3", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/rurock256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/rurock128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/rurock48k", + "type": "aac" + } + ] }, { "id": 404, @@ -4978,7 +5395,24 @@ "bgColor": "#CC8033", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/folk256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/folk128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/folk48k", + "type": "aac" + } + ] }, { "id": 405, @@ -4993,7 +5427,24 @@ "bgColor": "#994D00", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://abs.zaycev.fm/classic256k", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://abs.zaycev.fm/classic128k", + "type": "aac" + }, + { + "bitrate": 48, + "url": "http://abs.zaycev.fm/classic48k", + "type": "aac" + } + ] }, { "id": 423, @@ -5608,7 +6059,24 @@ "bgColor": "#D53080", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://retro.hostingradio.ru:8043/retro128", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://retro.hostingradio.ru:8043/retro64", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://retro.hostingradio.ru:8043/retro32", + "type": "aac" + } + ] }, { "id": 379, @@ -5743,7 +6211,19 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/rock-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/rock-64.mp3", + "type": "mp3" + } + ] }, { "id": 462, @@ -5848,7 +6328,24 @@ "bgColor": "#B12518", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://icecast-vgtrk.cdnvideo.ru/mayakfm_mp3_192kbps", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://icecast-vgtrk.cdnvideo.ru/mayakfm_mp3_128kbps", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://icecast-vgtrk.cdnvideo.ru/mayakfm_mp3_64kbps", + "type": "aac" + } + ] }, { "id": 497, @@ -5863,7 +6360,24 @@ "bgColor": "#881E91", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://icecast-vgtrk.cdnvideo.ru/kulturafm_mp3_192kbps", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://icecast-vgtrk.cdnvideo.ru/kulturafm_mp3_128kbps", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://icecast-vgtrk.cdnvideo.ru/kulturafm_mp3_64kbps", + "type": "aac" + } + ] }, { "id": 498, @@ -5878,7 +6392,24 @@ "bgColor": "#65A73F", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://icecast-vgtrk.cdnvideo.ru/unost_mp3_192kbps", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://icecast-vgtrk.cdnvideo.ru/unost_mp3_128kbps", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://icecast-vgtrk.cdnvideo.ru/unost_mp3_64kbps", + "type": "aac" + } + ] }, { "id": 496, @@ -5893,7 +6424,24 @@ "bgColor": "#428CF7", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://icecast-vgtrk.cdnvideo.ru/rrzonam_mp3_192kbps", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://icecast-vgtrk.cdnvideo.ru/rrzonam_mp3_128kbps", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://icecast-vgtrk.cdnvideo.ru/rrzonam_mp3_64kbps", + "type": "aac" + } + ] }, { "id": 499, @@ -5908,7 +6456,24 @@ "bgColor": "#FFFFFF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://icecast-vgtrk.cdnvideo.ru/vestifm_mp3_192kbps", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://icecast-vgtrk.cdnvideo.ru/vestifm_mp3_128kbps", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://icecast-vgtrk.cdnvideo.ru/vestifm_mp3_64kbps", + "type": "aac" + } + ] }, { "id": 502, @@ -6073,7 +6638,24 @@ "bgColor": "#FFFF4D", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://pub0201.101.ru/stream/pro/aac/64/396", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://pub0201.101.ru/stream/pro/aac/64/364", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://pub0201.101.ru/stream/pro/aac/64/332", + "type": "aac" + } + ] }, { "id": 544, @@ -6238,7 +6820,24 @@ "bgColor": "#F9DE4A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 64, + "url": "https://pub0202.101.ru:8443/stream/pro/aac/64/164", + "type": "aac" + }, + { + "bitrate": 48, + "url": "https://pub0202.101.ru:8443/stream/pro/aac/64/148", + "type": "aac" + }, + { + "bitrate": 32, + "url": "https://pub0202.101.ru:8443/stream/pro/aac/64/132", + "type": "aac" + } + ] }, { "id": 592, @@ -7243,7 +7842,19 @@ "bgColor": "#CB181B", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/jazz-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/jazz-64.mp3", + "type": "mp3" + } + ] }, { "id": 702, @@ -7363,7 +7974,24 @@ "bgColor": "#9F68CD", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 64, + "url": "https://srv11.gpmradio.ru:8443/stream/pro/aac/64/164", + "type": "aac" + }, + { + "bitrate": 48, + "url": "https://srv11.gpmradio.ru:8443/stream/pro/aac/64/148", + "type": "aac" + }, + { + "bitrate": 32, + "url": "https://srv11.gpmradio.ru:8443/stream/pro/aac/64/132", + "type": "aac" + } + ] }, { "id": 709, @@ -7903,7 +8531,24 @@ "bgColor": "#2B386A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://chanson.hostingradio.ru:8041/chanson256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://chanson.hostingradio.ru:8041/chanson128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://chanson.hostingradio.ru:8041/chanson64.mp3", + "type": "mp3" + } + ] }, { "id": 746, @@ -7918,7 +8563,24 @@ "bgColor": "#2B386A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://chanson.hostingradio.ru:8041/chanson256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://chanson.hostingradio.ru:8041/chanson128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://chanson.hostingradio.ru:8041/chanson64.mp3", + "type": "mp3" + } + ] }, { "id": 744, @@ -7933,7 +8595,24 @@ "bgColor": "#2B386A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic64.mp3", + "type": "mp3" + } + ] }, { "id": 747, @@ -7948,7 +8627,24 @@ "bgColor": "#2B386A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://chanson.hostingradio.ru:8041/chanson-romantic64.mp3", + "type": "mp3" + } + ] }, { "id": 745, @@ -8443,7 +9139,19 @@ "bgColor": "#E95650", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://silverrain.hostingradio.ru/silver128.mp3", + "type": "mp3" + }, + { + "bitrate": 48, + "url": "http://silverrain.hostingradio.ru/silver48.mp3", + "type": "mp3" + } + ] }, { "id": 453, @@ -8458,7 +9166,19 @@ "bgColor": "#C03736", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://stream.newradio.ru/novoe96.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://stream.newradio.ru/novoe32.aacp", + "type": "aac" + } + ] }, { "id": 457, @@ -8488,7 +9208,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://nashe1.hostingradio.ru/ultra-192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/ultra-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/ultra-64.mp3", + "type": "mp3" + } + ] }, { "id": 387, @@ -8503,7 +9240,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://nashe1.hostingradio.ru/ultra-192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://nashe1.hostingradio.ru/ultra-128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://nashe1.hostingradio.ru/ultra-64.mp3", + "type": "mp3" + } + ] }, { "id": 507, @@ -8578,7 +9332,19 @@ "bgColor": "#D8693C", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://stream1.radiord.ru:8000/live128.aac", + "type": "aac" + }, + { + "bitrate": 96, + "url": "http://stream1.radiord.ru:8000/live96.aac", + "type": "aac" + } + ] }, { "id": 712, @@ -8638,7 +9404,24 @@ "bgColor": "#B31A1A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://live.rzs.ru/ka.128.mp3", + "type": "mp3" + }, + { + "bitrate": 96, + "url": "http://live.rzs.ru/ka.96.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://live.rzs.ru/ka.64.mp3", + "type": "mp3" + } + ] }, { "id": 716, @@ -8833,7 +9616,24 @@ "bgColor": "#57B4EF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://stream.vyshka24.ru/256", + "type": "aac" + }, + { + "bitrate": 192, + "url": "http://stream.vyshka24.ru/192", + "type": "aac" + }, + { + "bitrate": 128, + "url": "http://stream.vyshka24.ru/128", + "type": "aac" + } + ] }, { "id": 741, @@ -8863,7 +9663,24 @@ "bgColor": "#B6362F", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 320, + "url": "http://maksfm.hostingradio.ru/maksfm320.mp3", + "type": "mp3" + }, + { + "bitrate": 192, + "url": "http://maksfm.hostingradio.ru/maksfm192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://maksfm.hostingradio.ru/maksfm128.mp3", + "type": "mp3" + } + ] }, { "id": 761, @@ -8983,7 +9800,19 @@ "bgColor": "#6680E6", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "https://online.intervolna.ru:8433/radio192", + "type": "aac" + }, + { + "bitrate": 128, + "url": "https://online.intervolna.ru:8433/radio128", + "type": "aac" + } + ] }, { "id": 805, @@ -8998,7 +9827,19 @@ "bgColor": "#6680E6", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "https://online.intervolna.ru:8433/radio192", + "type": "aac" + }, + { + "bitrate": 128, + "url": "https://online.intervolna.ru:8433/radio128", + "type": "aac" + } + ] }, { "id": 806, @@ -9088,7 +9929,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "http://bfm.hostingradio.ru/bfm256.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://bfm.hostingradio.ru/bfm64.mp3", + "type": "mp3" + }, + { + "bitrate": 32, + "url": "http://bfm.hostingradio.ru/bfm32.mp3", + "type": "mp3" + } + ] }, { "id": 835, @@ -9148,7 +10006,19 @@ "bgColor": "#FFFFFF", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 96, + "url": "http://stream.studio21.ru/studio2196.aacp", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://stream.studio21.ru/studio2132.aacp", + "type": "aac" + } + ] }, { "id": 839, @@ -9703,7 +10573,24 @@ "bgColor": "#5FBBE9", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 128, + "url": "http://radiovera.hostingradio.ru:8007/radiovera_128", + "type": "aac" + }, + { + "bitrate": 64, + "url": "http://radiovera.hostingradio.ru:8007/radiovera_64", + "type": "aac" + }, + { + "bitrate": 32, + "url": "http://radiovera.hostingradio.ru:8007/radiovera_32", + "type": "aac" + } + ] }, { "id": 677, @@ -9973,7 +10860,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm64.mp3", + "type": "mp3" + } + ] }, { "id": 855, @@ -9988,7 +10892,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://orfeyfm.hostingradio.ru:8034/orfeyfm64.mp3", + "type": "mp3" + } + ] }, { "id": 858, @@ -10003,7 +10924,24 @@ "bgColor": "#1A4D1A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb64.mp3", + "type": "mp3" + } + ] }, { "id": 856, @@ -10018,7 +10956,24 @@ "bgColor": "#1A4D1A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 192, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb192.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "http://orfeyfm.hostingradio.ru:8034/orpheuscwb64.mp3", + "type": "mp3" + } + ] }, { "id": 857, @@ -10123,7 +11078,24 @@ "bgColor": "#B3801A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_75_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_75_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_75_64.mp3", + "type": "mp3" + } + ] }, { "id": 865, @@ -10138,7 +11110,24 @@ "bgColor": "#4D3399", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_72_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_72_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_72_64.mp3", + "type": "mp3" + } + ] }, { "id": 866, @@ -10153,7 +11142,24 @@ "bgColor": "#000000", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_74_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_74_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_74_64.mp3", + "type": "mp3" + } + ] }, { "id": 867, @@ -10168,7 +11174,24 @@ "bgColor": "#4D4D4D", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_77_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_77_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_77_64.mp3", + "type": "mp3" + } + ] }, { "id": 868, @@ -10183,7 +11206,24 @@ "bgColor": "#996699", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_76_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_76_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_76_64.mp3", + "type": "mp3" + } + ] }, { "id": 869, @@ -10198,7 +11238,24 @@ "bgColor": "#CC6633", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_60_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_60_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_60_64.mp3", + "type": "mp3" + } + ] }, { "id": 870, @@ -10213,7 +11270,24 @@ "bgColor": "#994D66", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_61_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_61_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_61_64.mp3", + "type": "mp3" + } + ] }, { "id": 871, @@ -10228,7 +11302,24 @@ "bgColor": "#B31A1A", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_62_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_62_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_62_64.mp3", + "type": "mp3" + } + ] }, { "id": 872, @@ -10258,7 +11349,24 @@ "bgColor": "#4D66CC", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_64_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_64_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_64_64.mp3", + "type": "mp3" + } + ] }, { "id": 874, @@ -10273,7 +11381,24 @@ "bgColor": "#994D66", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_65_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_65_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_65_64.mp3", + "type": "mp3" + } + ] }, { "id": 875, @@ -10288,7 +11413,24 @@ "bgColor": "#CC6633", "enabled": true, "notWorked": false, - "isNew": false + "isNew": false, + "qualities": [ + { + "bitrate": 256, + "url": "https://radio.orpheus.ru:8000/Chan_66_256.mp3", + "type": "mp3" + }, + { + "bitrate": 128, + "url": "https://radio.orpheus.ru:8000/Chan_66_128.mp3", + "type": "mp3" + }, + { + "bitrate": 64, + "url": "https://radio.orpheus.ru:8000/Chan_66_64.mp3", + "type": "mp3" + } + ] }, { "id": 876, @@ -11449,4 +12591,4 @@ "stationsBeforeGroups": false, "version": 47 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/radiola/data/local/AppDatabase.kt b/app/src/main/java/com/radiola/data/local/AppDatabase.kt index 5628327..880592c 100644 --- a/app/src/main/java/com/radiola/data/local/AppDatabase.kt +++ b/app/src/main/java/com/radiola/data/local/AppDatabase.kt @@ -44,9 +44,15 @@ val MIGRATION_3_4 = object : Migration(3, 4) { } } +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE stations ADD COLUMN qualities TEXT NOT NULL DEFAULT ''") + } +} + @Database( entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class], - version = 4 + version = 5 ) abstract class AppDatabase : RoomDatabase() { abstract fun stationDao(): StationDao diff --git a/app/src/main/java/com/radiola/data/local/LocalStationDataSource.kt b/app/src/main/java/com/radiola/data/local/LocalStationDataSource.kt index 3f1c55d..40be480 100644 --- a/app/src/main/java/com/radiola/data/local/LocalStationDataSource.kt +++ b/app/src/main/java/com/radiola/data/local/LocalStationDataSource.kt @@ -41,7 +41,10 @@ class LocalStationDataSource @Inject constructor( genre = group?.name ?: "", tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }), sortOrder = dto.id, - source = if (isRecord) "record" else "local" + source = if (isRecord) "record" else "local", + qualities = dto.qualities.orEmpty().map { + com.radiola.domain.model.StreamQuality(it.bitrate, it.url, it.type) + } ) } } diff --git a/app/src/main/java/com/radiola/data/local/dto/LocalStationDto.kt b/app/src/main/java/com/radiola/data/local/dto/LocalStationDto.kt index d5dd8ea..5b2c934 100644 --- a/app/src/main/java/com/radiola/data/local/dto/LocalStationDto.kt +++ b/app/src/main/java/com/radiola/data/local/dto/LocalStationDto.kt @@ -17,7 +17,15 @@ data class LocalStationDto( @SerialName("bgColor") val bgColor: String? = null, @SerialName("enabled") val enabled: Boolean = true, @SerialName("notWorked") val notWorked: Boolean = false, - @SerialName("isNew") val isNew: Boolean = false + @SerialName("isNew") val isNew: Boolean = false, + @SerialName("qualities") val qualities: List? = null +) + +@Serializable +data class LocalQualityDto( + @SerialName("bitrate") val bitrate: Int, + @SerialName("url") val url: String, + @SerialName("type") val type: String = "aac" ) @Serializable diff --git a/app/src/main/java/com/radiola/data/local/entity/StationEntity.kt b/app/src/main/java/com/radiola/data/local/entity/StationEntity.kt index 32929e9..53236d5 100644 --- a/app/src/main/java/com/radiola/data/local/entity/StationEntity.kt +++ b/app/src/main/java/com/radiola/data/local/entity/StationEntity.kt @@ -14,5 +14,7 @@ data class StationEntity( val tags: String, val sortOrder: Int, val source: String = "record", - val isFavorite: Boolean = false + val isFavorite: Boolean = false, + // Качества потока, закодированы строкой: строки "bitrate\ttype\turl", разделённые \n. + val qualities: String = "" ) diff --git a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt index db41646..b6100b9 100644 --- a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt @@ -29,6 +29,7 @@ class SettingsRepositoryImpl @Inject constructor( private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services") private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled") + private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") } override fun getLastStationId(): Flow = dataStore.data.map { it[LAST_STATION_ID] } @@ -49,4 +50,7 @@ class SettingsRepositoryImpl @Inject constructor( override fun isRecordingEnabled(): Flow = dataStore.data.map { it[RECORDING_ENABLED] ?: false } override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } } + + override fun getPreferredBitrate(): Flow = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 } + override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } } } diff --git a/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt index 5ee3d01..63f507d 100644 --- a/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt @@ -8,6 +8,7 @@ import com.radiola.data.remote.RecordApi import com.radiola.data.remote.RadiolaApi import com.radiola.data.remote.ApiMapper.toDomain import com.radiola.domain.model.Station +import com.radiola.domain.model.StreamQuality import com.radiola.domain.repository.StationRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -84,7 +85,8 @@ class StationRepositoryImpl @Inject constructor( tags = station.tags.joinToString(","), sortOrder = index, source = station.source, - isFavorite = false + isFavorite = false, + qualities = encodeQualities(station.qualities) ) } db.stationDao().insertAll(entities) @@ -133,6 +135,22 @@ class StationRepositoryImpl @Inject constructor( genre = genre, tags = tags.split(",").filter { it.isNotBlank() }, sortOrder = sortOrder, - source = source + source = source, + qualities = decodeQualities(qualities) ) + + // Качества кодируем строкой "bitrate\ttype\turl" по строкам (URL может содержать ; и |, + // но не \t/\n — поэтому такие разделители безопасны). + private fun encodeQualities(list: List): String = + list.joinToString("\n") { "${it.bitrate}\t${it.type}\t${it.url}" } + + private fun decodeQualities(raw: String): List { + if (raw.isBlank()) return emptyList() + return raw.split("\n").mapNotNull { line -> + val parts = line.split("\t") + if (parts.size != 3) return@mapNotNull null + val br = parts[0].toIntOrNull() ?: return@mapNotNull null + StreamQuality(br, parts[2], parts[1]) + } + } } diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index 4b0a7af..33ef591 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -7,6 +7,7 @@ import com.radiola.data.local.LocalStationDataSource import com.radiola.data.local.MIGRATION_1_2 import com.radiola.data.local.MIGRATION_2_3 import com.radiola.data.local.MIGRATION_3_4 +import com.radiola.data.local.MIGRATION_4_5 import com.radiola.data.remote.AuthInterceptor import com.radiola.data.remote.LrcLibApi import com.radiola.data.remote.LoveApi @@ -136,7 +137,7 @@ object AppModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db") - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .build() @Provides diff --git a/app/src/main/java/com/radiola/domain/model/Station.kt b/app/src/main/java/com/radiola/domain/model/Station.kt index 7579039..ccd820b 100644 --- a/app/src/main/java/com/radiola/domain/model/Station.kt +++ b/app/src/main/java/com/radiola/domain/model/Station.kt @@ -9,5 +9,24 @@ data class Station( val genre: String, val tags: List, val sortOrder: Int, - val source: String = "record" + val source: String = "record", + // Доступные качества потока (битрейты). Пусто или один элемент — переключателя нет. + val qualities: List = emptyList() ) + +/** Один вариант качества потока станции. */ +data class StreamQuality( + val bitrate: Int, // kbps + val url: String, + val type: String // "aac" | "mp3" +) { + /** Человекочитаемая ступень качества по битрейту. */ + val tierLabel: String + get() = when { + bitrate >= 256 -> "Максимальное" + bitrate >= 128 -> "Высокое" + bitrate >= 96 -> "Среднее" + bitrate >= 64 -> "Экономно" + else -> "Минимальное" + } +} diff --git a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt index 29bd7ae..2f11578 100644 --- a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt @@ -14,4 +14,7 @@ interface SettingsRepository { suspend fun setEqualizerPreset(preset: String) fun isRecordingEnabled(): Flow suspend fun setRecordingEnabled(enabled: Boolean) + // Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции). + fun getPreferredBitrate(): Flow + suspend fun setPreferredBitrate(bitrate: Int) } diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index edf71aa..ba4256f 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -154,6 +154,18 @@ class PlayerController @Inject constructor( _currentStationPrefix.value = stationPrefix } + /** Сменить URL потока (переключение качества) без потери текущих метаданных/обложки. */ + fun changeStream(url: String) { + Log.d("PlayerController", "changeStream() url=$url") + val keepMetadata = exoPlayer.currentMediaItem?.mediaMetadata + _icyTitle.value = null + val builder = MediaItem.Builder().setUri(url) + if (keepMetadata != null) builder.setMediaMetadata(keepMetadata) + exoPlayer.setMediaItem(builder.build()) + exoPlayer.prepare() + exoPlayer.play() + } + fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) { val currentMediaItem = exoPlayer.currentMediaItem ?: return val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) } diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 4cd8b71..5a6b2f1 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -35,6 +35,7 @@ import coil.compose.AsyncImage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState +import com.composables.icons.lucide.Check import com.composables.icons.lucide.FileText import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Lucide @@ -46,6 +47,7 @@ import com.composables.icons.lucide.Play import com.composables.icons.lucide.Radio import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward +import com.composables.icons.lucide.SlidersHorizontal import com.radiola.deeplink.DeeplinkNavigator import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.Station @@ -77,6 +79,8 @@ fun PlayerBottomSheet( val colors = RadiolaTheme.colors val haptics = LocalHapticFeedback.current var showLyrics by remember { mutableStateOf(false) } + var showQuality by remember { mutableStateOf(false) } + val currentQuality by viewModel.currentQuality.collectAsState() Column( modifier = modifier @@ -86,14 +90,27 @@ fun PlayerBottomSheet( .padding(horizontal = 24.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Метка «В ЭФИРЕ» - Text( - text = "В ЭФИРЕ", - style = MaterialTheme.typography.labelSmall, - color = colors.accent, - letterSpacing = 2.sp, - fontWeight = FontWeight.SemiBold - ) + // Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = "В ЭФИРЕ", + style = MaterialTheme.typography.labelSmall, + color = colors.accent, + letterSpacing = 2.sp, + fontWeight = FontWeight.SemiBold + ) + val qualities = station?.qualities.orEmpty() + if (qualities.size >= 2) { + QualityChip( + label = "${(currentQuality?.bitrate ?: qualities.first().bitrate)}k", + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + showQuality = true + }, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } Spacer(Modifier.height(6.dp)) // Название радиостанции — под меткой, над обложкой @@ -309,6 +326,43 @@ fun PlayerBottomSheet( } } + // Шторка выбора качества + if (showQuality && station != null) { + val qualities = station.qualities + ModalBottomSheet( + onDismissRequest = { showQuality = false }, + containerColor = colors.bgBase, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp) + ) { + Text( + text = "Качество звука", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 12.dp) + ) + qualities.forEach { q -> + QualityRow( + quality = q, + selected = currentQuality?.bitrate == q.bitrate, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.selectQuality(q) + showQuality = false + } + ) + } + } + } + } + // Шторка текста песни if (showLyrics && track != null) { ModalBottomSheet( @@ -324,6 +378,80 @@ fun PlayerBottomSheet( } } +/** Компактный чип текущего качества звука. */ +@Composable +private fun QualityChip( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colors = RadiolaTheme.colors + val interaction = remember { MutableInteractionSource() } + Row( + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(colors.surface2) + .pressScale(interactionSource = interaction) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Lucide.SlidersHorizontal, + contentDescription = "Качество", + tint = colors.accent, + modifier = Modifier.size(13.dp) + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = colors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + } +} + +/** Строка выбора одного качества в шторке. */ +@Composable +private fun QualityRow( + quality: com.radiola.domain.model.StreamQuality, + selected: Boolean, + onClick: () -> Unit +) { + val colors = RadiolaTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = quality.tierLabel, + style = MaterialTheme.typography.bodyLarge, + color = if (selected) colors.accent else colors.textPrimary, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + Text( + text = "${quality.bitrate} kbps · ${quality.type.uppercase()}", + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary + ) + } + if (selected) { + Icon( + imageVector = Lucide.Check, + contentDescription = "Выбрано", + tint = colors.accent, + modifier = Modifier.size(20.dp) + ) + } + } +} + /** Обёртка для иконок-кнопок управления — прозрачный фон. */ @Composable private fun PlayerIconBtn( diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 164e86d..42d9f0f 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.Station +import com.radiola.domain.model.StreamQuality import com.radiola.domain.model.Track import com.radiola.domain.repository.SettingsRepository import com.radiola.domain.repository.StationRepository @@ -57,6 +58,13 @@ class PlayerViewModel @Inject constructor( private val _playlist = MutableStateFlow>(emptyList()) val playlist: StateFlow> = _playlist.asStateFlow() + // Выбранное качество текущей станции (битрейт). null — у станции нет вариантов. + private val _currentQuality = MutableStateFlow(null) + val currentQuality: StateFlow = _currentQuality.asStateFlow() + + // Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции). + private var preferredBitrate: Int = 0 + val isRecording: StateFlow = recordingRepository.isRecording private var nowPlayingJob: Job? = null @@ -72,6 +80,9 @@ class PlayerViewModel @Inject constructor( _enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids } } } + viewModelScope.launch { + settingsRepository.getPreferredBitrate().collect { preferredBitrate = it } + } viewModelScope.launch { _currentTrack .filterNotNull() @@ -86,10 +97,15 @@ class PlayerViewModel @Inject constructor( _currentStation.value = station _currentTrack.value = null _playlist.value = playlist ?: _stations.value + // Выбираем стартовое качество: предпочтение пользователя → совпадение с + // потоком по умолчанию → высшее. Если вариантов нет — играем как есть. + val quality = pickInitialQuality(station) + _currentQuality.value = quality + val streamUrl = quality?.url ?: station.streamUrl // Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку). // Для остальных resolve вернёт URL как есть. viewModelScope.launch { - val url = loveStreamResolver.resolve(station.streamUrl) + val url = loveStreamResolver.resolve(streamUrl) playerController.play(url, station.prefix, station.name) } viewModelScope.launch { pushHistoryUseCase(station.id) } @@ -142,6 +158,28 @@ class PlayerViewModel @Inject constructor( } } + /** Стартовое качество станции с учётом предпочтения пользователя. */ + private fun pickInitialQuality(station: Station): StreamQuality? { + val list = station.qualities + if (list.size < 2) return null + return list.firstOrNull { it.bitrate == preferredBitrate } + ?: list.firstOrNull { it.url == station.streamUrl } + ?: list.first() + } + + /** Переключить качество текущей станции на лету (без сброса now-playing). */ + fun selectQuality(quality: StreamQuality) { + val station = _currentStation.value ?: return + if (_currentQuality.value?.bitrate == quality.bitrate) return + _currentQuality.value = quality + preferredBitrate = quality.bitrate + viewModelScope.launch { settingsRepository.setPreferredBitrate(quality.bitrate) } + viewModelScope.launch { + val url = loveStreamResolver.resolve(quality.url) + playerController.changeStream(url) + } + } + private fun parseIcyTitle(title: String?): Track? { if (title.isNullOrBlank()) return null val separators = listOf(" - ", " — ", " – ")