diff --git a/android/Dockerfile b/android/Dockerfile deleted file mode 100644 index ce69d1b..0000000 --- a/android/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -FROM williamkyo/android-sdk:latest AS builder - -RUN apt-get update && apt-get install -y \ - wget \ - && rm -rf /var/lib/apt/lists/* - -RUN sdkmanager "platforms;android-35" "build-tools;35.0.0" 2>/dev/null || \ - sdkmanager "platforms;android-34" "build-tools;34.0.0" - -ENV GRADLE_VERSION=8.9 - -RUN mkdir -p /opt/gradle \ - && wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/gradle.zip \ - && unzip -q /tmp/gradle.zip -d /opt/gradle \ - && rm /tmp/gradle.zip - -ENV PATH=/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH - -WORKDIR /app - -COPY gradle/wrapper/ gradle/wrapper/ -COPY gradlew . -COPY gradle.properties . -COPY settings.gradle.kts . -COPY build.gradle.kts . -COPY app/build.gradle.kts app/ - -RUN chmod +x gradlew && ./gradlew :app:dependencies --no-daemon - -COPY app/src app/src - -RUN ./gradlew :app:assembleDebug --no-daemon - -FROM scratch - -COPY --from=builder /app/app/build/outputs/apk/debug/app-debug.apk /app-debug.apk \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts deleted file mode 100644 index 6e041b6..0000000 --- a/android/app/build.gradle.kts +++ /dev/null @@ -1,79 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "com.lofiradio" - compileSdk = 35 - - defaultConfig { - applicationId = "com.lofiradio" - minSdk = 26 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - debug { - isMinifyEnabled = false - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - buildFeatures { - viewBinding = true - } -} - -dependencies { - // Core - implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.appcompat:appcompat:1.7.0") - implementation("com.google.android.material:material:1.12.0") - implementation("androidx.constraintlayout:constraintlayout:2.2.1") - implementation("androidx.recyclerview:recyclerview:1.4.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - implementation("androidx.fragment:fragment-ktx:1.8.6") - implementation("androidx.preference:preference-ktx:1.2.1") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation("androidx.cardview:cardview:1.0.0") - - // ExoPlayer for HLS audio playback - implementation("androidx.media3:media3-exoplayer:1.5.1") - implementation("androidx.media3:media3-exoplayer-hls:1.5.1") - implementation("androidx.media3:media3-ui:1.5.1") - implementation("androidx.media3:media3-session:1.5.1") - - // Networking - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.retrofit2:retrofit:2.11.0") - implementation("com.squareup.retrofit2:converter-gson:2.11.0") - - // Image loading - implementation("io.coil-kt:coil:2.7.0") - - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") - - // Gson for JSON parsing - implementation("com.google.code.gson:gson:2.11.0") -} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index 7aee25a..0000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,13 +0,0 @@ --keep class com.lofiradio.data.models.** { *; } --keep class com.lofiradio.network.** { *; } --keep class com.lofiradio.ui.** { *; } --dontwarn com.google.gson.** --dontwarn androidx.media3.** --dontwarn okhttp3.** --dontwarn coil3.** - -# ExoPlayer media3 HLS rules --keep class androidx.media3.exoplayer.hls.** { *; } --keep class * implements androidx.media3.exoplayer.hls.HlsTrackEntry$FormatExtractorFactory { *; } --dontwarn androidx.media3.exoplayer.hls.** --keepclassmembers enum androidx.media3.exoplayer.hls.HlsMimeType { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index ecbcd90..0000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/LofiRadioApp.kt b/android/app/src/main/java/com/lofiradio/LofiRadioApp.kt deleted file mode 100644 index c2c51b6..0000000 --- a/android/app/src/main/java/com/lofiradio/LofiRadioApp.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.lofiradio - -import android.app.Application - -class LofiRadioApp : Application() { - override fun onCreate() { - super.onCreate() - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/data/Channels.kt b/android/app/src/main/java/com/lofiradio/data/Channels.kt deleted file mode 100644 index 7295524..0000000 --- a/android/app/src/main/java/com/lofiradio/data/Channels.kt +++ /dev/null @@ -1,699 +0,0 @@ -package com.lofiradio.data - -import com.lofiradio.data.models.Channel - -val CHANNELS = listOf( - Channel( - id = "UCSJ4gkVC6NrvII8umztf0Ow", - name = "Lofi Girl", - handle = "@LofiGirl", - description = "The most popular lofi hip hop radio - beats to relax/study to", - thumbnail = "https://yt3.googleusercontent.com/_BSh2VVvVMzqBoKyWbQnyC35XFOV-ZbXavf9nfu3ZjpFUGEImQnlWt9ZlpfGQBqWEbGNc4rPWg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCOxqgCwgOqC2lMqC5PYz_Dg", - name = "Chillhop Music", - handle = "@ChillhopMusic", - description = "Jazzhop, lofi, chill beats for studying and relaxing", - thumbnail = "https://yt3.googleusercontent.com/5sz00tGeNdll17IqVECF7s7shUzz0nlirAK86WgY0yz7-4t2S51_XMvjM7HaJfdwNlM6rm_Hrg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCFLPmPdKzkwubQjtMX46r7Q", - name = "Sleepyfish", - handle = "@Sleepyfish", - description = "Sleepy lofi beats to help you relax and drift off", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_nfjqbY7TFPjKb3R-QWfuReRCHVZ2mccVbYz23qLRPEUQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCN4y5bU7xuKlc86wfzaDk6w", - name = "Gym Lofi", - handle = "@GymLofi", - description = "Lofi beats for your workout sessions", - thumbnail = "https://yt3.googleusercontent.com/0wL0QE3VcI19jZM-MWAS8JOSaKfF3L7bhkSuQHuTAW3IzptC9AS4JSVPaPjdE4dpa7ELLltOjw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCBTKBe2IUs9SURddQOT6mpA", - name = "Study Lofi", - handle = "@StudyLofi", - description = "Focus beats for studying and concentration", - thumbnail = "https://yt3.googleusercontent.com/XvuMl2pI3VZUUZL8N79H9doTN5N_kMTnkbfZewJ0ljR9yv_2oa1FScL0SyoKNaO2omxzoQYm1-A=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCnV2UaGCuZzepjpQNVGXHAA", - name = "Lofi Fruits", - handle = "@LofiFruits", - description = "Fruity lofi beats for a sweet relaxation experience", - thumbnail = "https://yt3.googleusercontent.com/J0d10uCXZc5MfSeUAf_e7S4FfKf4h0ABntObMeA8--yqvsLinuvAscr9kpjFhz_QbXTXGrl9DQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCfj4xwi09E5lWnBay8KYkAA", - name = "Lofi Tokyo Dreams", - handle = "@LofiTokyoDreamsOfficial", - description = "Tokyo-inspired lofi dreams and cityscapes", - thumbnail = "https://yt3.googleusercontent.com/xvvRyUe7JlR48VBlk_djsKIFtAc9E1dX9gMeRO2XWdgm4JdMMBgv6jQ4aUD1sangY1OKmSvzCw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCZ8g_Awlv5bj0q0oC8rHwOw", - name = "Lemon Lofi Vibes", - handle = "@LemonLofiVibes", - description = "Fresh lemon lofi vibes for a bright mood", - thumbnail = "https://yt3.googleusercontent.com/bfWQ8FzOG61KvoKUhEieg5E1ks3LpScq_k8PlwcFXC2_RocFxocUXiAS7FvJX8DZxkcegbD3XQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCChP8U3LI7M3jAPsbKe0b1w", - name = "Little Lofi Cute", - handle = "@LittleLofiCute", - description = "Cute and cozy lofi beats for relaxation", - thumbnail = "https://yt3.googleusercontent.com/XY5I62ZTW4KGHIKYSX3dzfW6WZ1f0khRSAmHGQ-tOL2Q8oqkuJXJuLLEQzXPfsrRj9PTtiGx=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCtT5o5ovUH19FEPzYDvw7KQ", - name = "Afro Lofi", - handle = "@afrolofi", - description = "African-inspired lofi beats and rhythms", - thumbnail = "https://yt3.googleusercontent.com/ZFLsGzR5guDF8RfuuhjxxPwmaxU4-E420-TMt_XIH6Ac_bvlxZAah22wSIFVf0ITmTegncd-ug=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCGlBgoZTfFmwJ2OwR6nKJWA", - name = "Retro Rhythm", - handle = "@retrorhythm", - description = "Retro rhythms and vintage lofi beats", - thumbnail = "https://yt3.googleusercontent.com/b2g1x73TgG1CRZEWedsjfnuUFlVbRZO4GB728MZ2XByxyN-bNvSpak8Hxgxi6TODCbGoo7s6Tg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC0fiLCwTmAukotCXYnqfj0A", - name = "The Bootleg Boy", - handle = "@thebootlegboy", - description = "Bootleg lofi remixes and underground beats", - thumbnail = "https://yt3.googleusercontent.com/2Ffl_YYLewfDeWsDjRJevqcMJBZuZwg05Y_fChz_OU_OocbQbrUUNkzH_ySveha9a348j5RyFFE=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC9OIZ77MhlVoi4IxLFXl-nQ", - name = "Tokyo Tones", - handle = "@TokyoTones", - description = "Tokyo-inspired tones and city pop lofi", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mk0eso1j7BVyOiHSouqRVHgUiOTn6s2_-iPvJ4M-1JF00=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCA0-hrRPtkPh0VZDZM5b4vA", - name = "Kuri Lofi", - handle = "@KuriLofi", - description = "Warm lofi beats with a Japanese touch", - thumbnail = "https://yt3.googleusercontent.com/l7EeE_DFIDWSwid_ylKbDiRR5LUFXLdj8a2P7VdaaaXoBnHmTrynyKxWEzvvvYF5rKSbgAt1=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCVWGstdY3EBG5jLy77VBgvg", - name = "Chill Out Lofi Music", - handle = "@ChillOutLofiMusic", - description = "Chill out lofi music for relaxation", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_l4q7tXiBrzdx4SKVC8yjQgU7JD3bwwApjfOT4OfVB6UQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCiYQtJcBpw0iDi1vge7A3VA", - name = "Lofi Layla Radio", - handle = "@LofiLaylaRadio", - description = "Layla's lofi radio for 24/7 beats", - thumbnail = "https://yt3.googleusercontent.com/fLYnU3ZuVYPI18O12C_XN3aAw5GGRMr8tiuJsyOi4r7o88Eb_wcjO0DlNWgVewAGl1vxcF5pow=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCy-T_vkbbOzOfP8TtBwkQ9A", - name = "Lofi Axol", - handle = "@LofiAxol", - description = "Axol's lofi beats and chill vibes", - thumbnail = "https://yt3.googleusercontent.com/bBW8Xb_eCJAt75avl91dc5tYfF6p2fmb3eLXwd3faRy95JpDPGtS8lF4TDwpZDOPRhHf2xEEhg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCOxtz12SJHLzY2Ylo5O9jpA", - name = "Fantasy Lofi", - handle = "@FantasyLofi", - description = "Fantasy-themed lofi beats and magical vibes", - thumbnail = "https://yt3.googleusercontent.com/59bpVDbHhcTcAS3KsNVZlGnRJWcfBy6Yhsb0Nn-VN-X9WVVB_uZe1U83coJqZnyLQjANLu4TNEw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC4CIdT66HOn4ekJVtNO4Qdg", - name = "The Anime Lofi", - handle = "@theanimelofi", - description = "Anime-inspired lofi beats and vibes", - thumbnail = "https://yt3.googleusercontent.com/TDXLbwJ0JJwZUKDZ5tx0lupUz3Aqo740dltwkzzAjuecEae5z3pWClUSmRZiybvdRL72oSX5=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCZJgUI_GHPtGzgKDX5fd5ug", - name = "Synthwave", - handle = "@Synthwave", - description = "Retrowave synthwave radio - driving into the neon sunset", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_kQOi47fKmr7VDOu4IcqV3CKkLHtkdmhT9Q3T-xtKnYLQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCIJoLZB58Pr5IuRCKTWU03g", - name = "Neon Night Drive", - handle = "@NeonNightDrive80", - description = "Neon-lit synthwave for night drives", - thumbnail = "https://yt3.googleusercontent.com/J0HdrdNbG8fP2F67mlvOjvd04m7Qniz0j_OAptAZNcbVZEJvrEQfVTlQxiUCe9J3yB3GF42W8w=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCCGwlz1e1ke32Z0QlpPsQdQ", - name = "New Retro Net", - handle = "@NewRetroNet", - description = "Retro net synthwave and outrun beats", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_nVsuvMP7gZfpywpeLE9Da5F7YhrpUKMaEa5QU1zNp6DXM=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCD-4g5w1h8xQpLaNS_ghU4g", - name = "New Retro Wave", - handle = "@NewRetroWave", - description = "New retro wave synth and electronic beats", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mUNOKQGUK_JbpYN_9cZwpIigj1yyoSl5TQ8PONJ82GB0I=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCWFwan02r888uUSizV__y7w", - name = "Nostalgic Synthwave", - handle = "@NostalgicSynthwave", - description = "Nostalgic synthwave for 80s vibes", - thumbnail = "https://yt3.googleusercontent.com/-BgleAs8Od00FPTw5hAegpcDB9rfa2MbPuHbkuN0hBjFHfkUWuUiu9ifWqVoim9xefeQj-51Cw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCHYxYgm5wmXXbQTRjEUYWfA", - name = "Retro Pulse FM", - handle = "@RetroPulseFM", - description = "Retro pulse synthwave radio", - thumbnail = "https://yt3.googleusercontent.com/y9GNb6aJRqghGxiKg7d8mpSpRMZ8UghSOQie0bgZgDHfb_BoCRrRs0jpA_no4SXGN6xR95CIiYw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC-6oT0FOyAqCGfdNLi4fmXA", - name = "Nestalgia Music", - handle = "@nestalgiamusic", - description = "Nostalgic music and retro vibes", - thumbnail = "https://yt3.googleusercontent.com/4KyiWYz5cb5A9o7lixz_JuH3HR1Mm4mPBr1ympNKLYXrTqlFzMMV9irQGCFvIFlv5hYN81zz9g=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCB5N4w_M0dQ7WQMdhbo3F0Q", - name = "Cassette Dreams", - handle = "@CassetteDreamsz", - description = "Cassette-inspired lofi and vaporwave dreams", - thumbnail = "https://yt3.googleusercontent.com/biZ_vlEFQRWnxCpy_UFp1y9vi7RzV99hI8RAiH9RyY6LISmmPrVWJNoMslXszW5PfRguATd1=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCDhsul46SOkngNl899Fnjyg", - name = "Cyberpunk Music Lab", - handle = "@cyberpunkmusiclab6695", - description = "Cyberpunk-inspired electronic music", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_ne1pPCNH7o9xo0AcG33QCPQgbMwlaSn4tDj90WoNWjvg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCqi2s9vPPExltBzpSTLhYaw", - name = "Glitch Black", - handle = "@GlitchBlack", - description = "Dark glitch and experimental electronic", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_kysY0Ape-zZTfVrkM_gxKtHjAdmjfdm4kbHVw3YFs2kjQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCTVbOTvSFbrPwN13ovB_MUw", - name = "Sponk Wave", - handle = "@SponkWave", - description = "Sponk wave and retro electronic beats", - thumbnail = "https://yt3.googleusercontent.com/n9dBEZoEACP5RWHEKW27WM99x8q7MLoEPfn43cS0siR-odEKzCy81uD52mAT0eZ_CYBPfGZp=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC5Sl4VbJELXi9SCaWbTdXfA", - name = "Jazz Cafe Ambience", - handle = "@JazzCafeAmbience", - description = "Smooth jazz cafe ambience for relaxation", - thumbnail = "https://yt3.googleusercontent.com/gzBzC7Sd8W73dxVJ3JR4jpSlos_zJEjZ61kOqs_x-Dojm0TXOJFjeDePSEdWVyyPKSe43h5x=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCRVO_YZslLcQ-CIGkWOC-Qw", - name = "Coffee Shop Ambience Jazz", - handle = "@CoffeeShopAmbienceJazz", - description = "Coffee shop jazz ambience for focus", - thumbnail = "https://yt3.googleusercontent.com/rZNawSKVPav3qAf6Nm85MOI6ax-Cl15BqcIhnB8CmhM3U8RozYwyUQjnhahZvnyQF3EIcn64=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCiLZqIgKpHHTVwFP9_YNTWg", - name = "Bossa Bossa Bossa", - handle = "@Bossabossabossa", - description = "Bossa nova rhythms and smooth vibes", - thumbnail = "https://yt3.googleusercontent.com/YFV851u9LttKCGmDxcWtdP3PEkMYxjrpGYs4M91hL3i2jh5FqMwdI60qlgIbxlLawBnYFJ4BvKs=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCpuuoKx5EgzHcFqmpWLkJeg", - name = "Bossa Cafe Music", - handle = "@BossaCafemusic", - description = "Bossa nova cafe music for relaxation", - thumbnail = "https://yt3.googleusercontent.com/Ld_Ul9VqgSD4XCfqGbHwcUAvP5egG4dXefd0tuIOsEixms755ctiWwVpu7H1WwGKOLZRvoLorWk=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCxHKHrkbtWYu1Nbx9Sn2Ztw", - name = "Bossa Lounges", - handle = "@BossaLounges", - description = "Bossa nova lounge music for unwinding", - thumbnail = "https://yt3.googleusercontent.com/fx9n84KOY8DODmFyrHqa-K6dAZ3HQK-x63qdlIAVJnUaLBZy7kDfVGNqwLAvgY69dbrjiQPoyw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCFjM4SzH8zAvsC0azlStgaw", - name = "Bossa Nova Jazz Channel", - handle = "@bossanovajazzchannel", - description = "Bossa nova jazz for smooth relaxation", - thumbnail = "https://yt3.googleusercontent.com/vLZPlFqzYMu-lSso_V0fMQmxyMmO4hjgdM1-aPQeGdY8AXLkQnw2uCQ72blFLe3MHh3FdSzMAQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCP1unfBUSCu-gf5H2ZhIk1g", - name = "Bossa Nova Popular", - handle = "@BossaNovaPopular", - description = "Popular bossa nova tracks and vibes", - thumbnail = "https://yt3.googleusercontent.com/ewM9r1qcKtGRBbrSo6lSPkdPMbXxdGHwIxfi98sjDLMCS6aEypw6DGBeeepJ2HoqGSSHDyY6gg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCZ018oE9ESp_0EljOTNX9-w", - name = "Cappuccino Jazzanova", - handle = "@CappuccinoJazzanova", - description = "Cappuccino-inspired jazz for coffee lovers", - thumbnail = "https://yt3.googleusercontent.com/8uNUPlTpjgcYP3zyBIyRWr8Tn05r-w9Z1nlrgIa_w7DerCbQxBi4gIZWx1C8Hw87dTtEcRDA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCicGSpg0J8OyQ2r2xs4Zrpw", - name = "Pure Jazz Sanctuary", - handle = "@PureJazzSanctuary", - description = "Pure jazz sanctuary for deep relaxation", - thumbnail = "https://yt3.googleusercontent.com/i3uUGEj38Z09NXrSL2AawRMih2IkVupSHbAqVADF6usjs0k4sokQFlifIPPiV6EWu7l2Wkre7g=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCkRXwVKzbjAsWTWgol1lKsg", - name = "This Is Jazz Noir", - handle = "@thisisjazznoir", - description = "Jazz noir for dark and moody vibes", - thumbnail = "https://yt3.googleusercontent.com/Qr7ZBOvIFFfBxZecZR2tS5jPueIqpIRduXHrkaT5t0TkCSkP-MaCO-nOc3ShsRrhMqxc32RgCQc=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCmCB1HBrYFuBPyvkcweFhlg", - name = "Smooth Moonlight", - handle = "@SmoothMoonlight77", - description = "Smooth moonlight jazz for late nights", - thumbnail = "https://yt3.googleusercontent.com/lOtlEtf3h6iz0JD6PG9hmXMVL9sI4vUHE9S1a6JfmFilWcq7qYFTIxhi0OpMtpJFxmlGPG_uxKk=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCZR3-lM6Z-n5_UGHlwx_Rpw", - name = "Relax Jazz Cafe", - handle = "@RelaxJazzCafe1990", - description = "Relaxing jazz cafe music from 1990", - thumbnail = "https://yt3.googleusercontent.com/ch7-gru5HaQqKEkGHQwCP4hmVmjD08v6u6Zl-S5lBJiZKskbpbwBIb1jTgm4xJWS4gxt5NMieA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCK4riubfpFSNhACUvXCk0Gg", - name = "Soft Morning Jazz", - handle = "@SoftMorningJazz", - description = "Soft morning jazz for a gentle start", - thumbnail = "https://yt3.googleusercontent.com/fFDZjqtWIZyyO6_3JnRdOim-YACbI8QDHMwKWce8xmbe6S_aD5Sf7bbZBmErwfgk2ptk6UBq6c4=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCppYFSXQ9YTbqtdjOep0ufg", - name = "J Pool Music", - handle = "@JPoolmusic", - description = "Pool-side jazz and chill vibes", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_l69jy33MRg0-sNSUoKzgrJv_2KS1YQASMaqB0iajacgxM=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCS-7DLlM6p8sYaDKjkYe8FA", - name = "Tranquil Cafe Jazz", - handle = "@TranquilCafeJazz", - description = "Tranquil cafe jazz for peace", - thumbnail = "https://yt3.googleusercontent.com/syfthbGfs46MjARxw9pw6L9ZZnxuk19-sZui7vT8xONQ64pdiwy1DGjvssUB1AhCjW9HbeFGshg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCTco4QOIOzvOacXmNZ643rQ", - name = "Sweet Jazz Cafe", - handle = "@SweetJazzCafe", - description = "Sweet jazz cafe for a cozy atmosphere", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mbdw79JGc5mlEFb4L_fDS_eNfnZ3cB5TODqlDQLt_5YWQ-7_FLklstw7UB2tMe0YfK6w=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC0t_KwohSkvbJ-96UoCKysA", - name = "Nova Jazz", - handle = "@NovaJazz", - description = "Nova jazz for modern relaxation", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_kXrVzf2kng6Bt3fDoTQur3nO0uITlQpQYpnFjviaOOtG-Tqfo3rW8xJWXn5hrlnURcGA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC3Kags250mSxyV-0R5s5qUQ", - name = "Golden Note Room", - handle = "@GoldenNoteRoom", - description = "Golden note jazz room for premium vibes", - thumbnail = "https://yt3.googleusercontent.com/pa7fAkBWAJWjq5ofzNUk8GswyNUMZBSAB9jEZAzxnM3yZZVewdwL6v1tREUaS9gcIcKidIVp=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCzo-s-TNyLPq41bW7VUDQ0w", - name = "Classical Oasis", - handle = "@classicaloasis", - description = "Classical music oasis for deep relaxation", - thumbnail = "https://yt3.googleusercontent.com/3Legqqhl1N__Wtr5rwAd67kOopev1ew8F-qH3F8mvKc0MwZO_lkkY_22cLgvf97wQ-2wYyBY=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCcbGEzL5fNAhqijZL8wX7zQ", - name = "Just Classical", - handle = "@justclassical", - description = "Pure classical music for focus", - thumbnail = "https://yt3.googleusercontent.com/DDR6WlUoDf-wINED-PuM0EoPjCmBBc5xAw7xl1R7qwKyfjL27tZDTX9zrUDRPcdhs5erJ52Y=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC_kqgIRwOD3XCZXXr4B6bPQ", - name = "DW Classical Music", - handle = "@DWClassicalMusic", - description = "DW classical music broadcasts", - thumbnail = "https://yt3.googleusercontent.com/44X3mAeQaaV8xZeFaIA2qJk2tVVQ8SYz1ntyxz9Pv8HRt_90vtmipkPQIeTNjgHW0Lp88q3j=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC-smeLB9AnOTeypr1YyjJ3A", - name = "Arte Concert", - handle = "@arteconcert", - description = "Arte concert classical performances", - thumbnail = "https://yt3.googleusercontent.com/n4N9HRJyehIwaMc_LajTA_H1ixfbmEkmkwEu_-St1U01GNgYLVytQdvvH4CYs00_46oj4AYLCgw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCcnOcXZK7Zns0WJXQrPOb2Q", - name = "Canon Classical", - handle = "@canonclassical", - description = "Canon classical music collection", - thumbnail = "https://yt3.googleusercontent.com/6s8KlJG9N1pbCO_ay-qUnqJdXSriaWo0rztlGcA1rQnnf81lq_bm34EiuDuX12JvvZQclQ726w=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC34DbNyD_0t8tnOc5V38Big", - name = "Deutsche Grammophon", - handle = "@deutschegrammophon", - description = "Deutsche Grammophon classical recordings", - thumbnail = "https://yt3.googleusercontent.com/jxZwgLUVlayzv3G-M1gLstGfWLDI-Tn06tUysMexZNwzNei1tytkr5XDv905feXG4uCdTL7bMQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCPUB5D0J_TStRlg399Mfc8A", - name = "Oslo Philharmonic", - handle = "@OsloPhilharmonic", - description = "Oslo Philharmonic orchestra performances", - thumbnail = "https://yt3.googleusercontent.com/IWq6fyrpQ9X5hAFzfuVxLjK4_YARHjk-_dRYiCecHG45wjhIE1YmL0an6HClzBkOjZHwy0BF=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCTKLQNGNPyb182JxSbyPCgQ", - name = "Baltic Sea Philharmonic", - handle = "@BalticSeaPhilharmonic", - description = "Baltic Sea Philharmonic orchestra", - thumbnail = "https://yt3.googleusercontent.com/E7xQafQt2gw40EybufXGc2Fbt8VOrzUfFrFTFolbTWFSaZSfnQuuHMywUTlAPTpd3v9gvd5vOw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCQWm2uTSX_Un3qrZsimMgDQ", - name = "Ne Prosto Orchestra", - handle = "@NeProstoOrchestra", - description = "Ne Prosto Orchestra classical performances", - thumbnail = "https://yt3.googleusercontent.com/70HGRoYIh6mKhv7v_YIALSvTUX8yUQmRRl_RcVzL7enVykU0oLSb19tmJTfIvisdG1u5nSOi=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCf1EknL5GWR1GyyC0LlIgBQ", - name = "Lyceum Philharmonic", - handle = "@LyceumPhilharmonic", - description = "Lyceum Philharmonic orchestra", - thumbnail = "https://yt3.googleusercontent.com/l7w6qC3v1rEWJrl3ZYYvyzeQ2KLiu2qgaT3ZNBX8PjmhnYU7RI3SYEmSkSsUVHofsdtHkgHYlw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC0KnpsNHuZPwJnF_RVbTrLw", - name = "Classical Music Compilation Zone", - handle = "@ClassicalMusicCompilationZone", - description = "Classical music compilations for focus", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_nc6UQSQSGqofMrLMib1pvFWKULW7u0HktTkve2k5ddOA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCJ80_CMnIOrKtMyFbIFIQ7A", - name = "Nightride FM", - handle = "@NightrideFM", - description = "Nightride FM deep house and techno", - thumbnail = "https://yt3.googleusercontent.com/U_bXLU8CTmYo3LHxZP00zxYnL5wE6socvOK4B5oOymD4r82a3Dyr09n21N0ZmCWBilmygCUpubU=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCnOxaDXBiBXg9Nn9hKWu6aw", - name = "Defected Music", - handle = "@DefectedMusic", - description = "Defected Music house and disco", - thumbnail = "https://yt3.googleusercontent.com/gWdgYFBUdFm3hoAijXcWg3sXmDU6q9V1pjR_QTuJX-_0NJQx4xLwkpv4-gDjdoP3RmZEsNkJ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCs2cG_5juMhivutuveanCiw", - name = "KDR Music House", - handle = "@kdrmusichouse", - description = "KDR Music house and electronic", - thumbnail = "https://yt3.googleusercontent.com/ah4cI00wnC6AAQkXRH3TmYCY-CtqBo6BG2i8hEfLlphPHPzlHAp2wqmMdDsWT8l-lV3eXZZRSg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCy2PiFPBY3_szhiVcOWBfgw", - name = "Peace House Tunes", - handle = "@PeaceHouseTunes", - description = "Peaceful house music for relaxation", - thumbnail = "https://yt3.googleusercontent.com/O8FOszNM9hn2uBcRYVqV1yQhZw8eHtpgdop6fNNJM62i4n_HapWZ2g03G5xERRuEEVlKrqroqpg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCGZXYc32ri4D0gSLPf2pZXQ", - name = "Armada Music TV", - handle = "@armadamusictv", - description = "Armada Music trance and progressive", - thumbnail = "https://yt3.googleusercontent.com/-5P3p8rEXiE5hqjsJ6KiHlf4ToVRHl7Gor15bhIFgw73aUSlA1KjMqrx3PWYcBvNg9PJYXA02A=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCbDgBFAketcO26wz-pR6OKA", - name = "Anjunadeep", - handle = "@anjunadeep", - description = "Anjunadeep deep house and melodic", - thumbnail = "https://yt3.googleusercontent.com/Gl5BB12JN5UugNAqyVgAS9SM4cwNOWl_-DP_Nynx-aZl8U1McqeraK4OM6VRsMras9g6Dzhi=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCw49uOTAJjGUdoAeUcp7tOg", - name = "Hospital Records", - handle = "@hospitalrecords", - description = "Hospital Records drum and bass", - thumbnail = "https://yt3.googleusercontent.com/nrP74cXHfHc6k-ZDSvqnk2w8HosW0Nyd1d5F3rZvFeNruIfYz5UvHId5k7gpa_8gqJvKsPEZ6ik=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCX4sShAQf01LYjYQhG2ZgKg", - name = "Monstercat Silk", - handle = "@monstercatsilk", - description = "Monstercat Silk chill and melodic", - thumbnail = "https://yt3.googleusercontent.com/VmwWA_exW4qoX1YSPfCJlpDh3lDjguBsaXSSPu6yltzZoLcu7oXvcCAUUheg8SLLzGWvnURTbA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCKpOFjnfAxjwdt9B7F71GqA", - name = "Elevate Records", - handle = "@ElevateRecords", - description = "Elevate Records deep house and tech", - thumbnail = "https://yt3.googleusercontent.com/otJNtKTMgHCarBuk5DE8fH9EfsULnf3QdfgAMJN3aaefqNY_2EjepNStgrAkmyVvnhmud24-_V0=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCvYuEpgW5JEUuAy4sNzdDFQ", - name = "Black Hole Recordings", - handle = "@blackholerecordings", - description = "Black Hole Recordings techno", - thumbnail = "https://yt3.googleusercontent.com/LR2voNEVq8PU4imXAkkaG_XFQkHLIkRKv70x8eRxFKuhr9HhTHTylSbEatpaU50AkyoxhUfw=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCDf6reK_hHcz0d7KWBhmPhA", - name = "Art Of Minimal Techno", - handle = "@artofminimaltechno", - description = "Art of minimal techno beats", - thumbnail = "https://yt3.googleusercontent.com/I-229_uSZ_1Ss32SBk---aSQcEROvW2RYGeqKcbRKmDHfJ6T59G5bEqOW1MAa5oLfMuLZeHV=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCqY3gnYuuzONai4jUhnCv6g", - name = "Minimal Group", - handle = "@MinimalGroupOfficial", - description = "Minimal Group techno and minimal", - thumbnail = "https://yt3.googleusercontent.com/bptutUqNVB44huffgzk2RStIT3lzC3KtVYcKKa9EqW_tIsAg2Ud_YmzehnhWB8uf0ilx0l1X=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCva3iYHZRE86jaHF1seHJxA", - name = "Minimal Maximus Technomania", - handle = "@MinimalmaximusTechnomania", - description = "Minimal maximus techno mania", - thumbnail = "https://yt3.googleusercontent.com/q5IFMMpOW6fDfYPi_k45NCaamDbORhQVVB2jN3NvGLEmJIhnMw808QrXVRR1mQ-NJNMzKSBX=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCKf1tehxoPaA6z0vJDoLR8A", - name = "Subatomic Sound", - handle = "@subatomicsound", - description = "Subatomic Sound electronic and techno", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mjHxTSlZL9atJzB68bpAuFe7KGaaV1l5Xw50eDXhalxLM=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCalCDSmZAYD73tqVZ4l8yJg", - name = "A State Of Trance", - handle = "@astateoftrance", - description = "A State Of Trance radio and mixes", - thumbnail = "https://yt3.googleusercontent.com/1xT2Ct8u5EfpFQNs_zuQb5g1xtNSlwhzchqnWjCPb59-DXulmdK8ABYwE8_49xDi1kGitBWdPIM=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC7EepV8oA9GoW_nDeCAWrkw", - name = "Trance Fy Music", - handle = "@TranceFyMusic", - description = "Trance Fy Music trance radio", - thumbnail = "https://yt3.googleusercontent.com/Bfd1IQhJv2hmwXMfWqPhTpfnhh1cjpBouRWd7eDMZYp5rwrLi9YjA06XRmZnRMz03InAjtvHfRU=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCQcdnG6NngIjyf80nZWRZ5Q", - name = "Trance Whispers", - handle = "@TranceWhispers", - description = "Trance Whispers ambient trance", - thumbnail = "https://yt3.googleusercontent.com/Q3tBuBeDSeqfTaq-I6p-9lv6RXnzZr-j-_f1xufEb_kdghkJQZtoONjBpb9lEriqVFrrTNrc3g=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCbupDeH76m1O9vszk1nTIog", - name = "Psy Love", - handle = "@PsyLove", - description = "Psy Love psytrance and Goa", - thumbnail = "https://yt3.googleusercontent.com/F6-1-HAZorcNF6K46Joj4mT8wJjD1ysUSuELz1qY8dg2gMbcAbU4m42eDLNoE0zQTj886fla=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCHSPZJD03gr4IibWb0eOROA", - name = "Northern Dubstep Pas", - handle = "@northerndubsteppas", - description = "Northern dubstep and bass music", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mq9LVF6g-XeemRzoVxMQBnG_mCux_LK2SfrZtiyUsFXg=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC_i0BwEcw4HHzSz2vMyxbDA", - name = "Ambient Lofi", - handle = "@ambientlofi", - description = "Ambient lofi soundscapes for meditation", - thumbnail = "https://yt3.googleusercontent.com/BgpSgxV_wGR-TKQNtCJX4kxugFVUUJdjE5djhqga-HJNbsD0ZCCgZKBxCmJZ2R0XRTX7N-JO=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC4sRUMkLcE4cImnKPoUo-Hg", - name = "Relaxing Ambient Escape", - handle = "@RelaxingAmbientEscape", - description = "Relaxing ambient escape for peace", - thumbnail = "https://yt3.googleusercontent.com/pVjrEubSQfJu0xlxqmjdtataNAm0Z6I6G6QGmUGRsozrXbjCFlEXWh9Jn29jUEm-U3mzjFI0nA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCF02M7hAKgRNq7jmMAYh2bg", - name = "Futurescapes", - handle = "@Futurescapes", - description = "Futurescapes ambient and electronic", - thumbnail = "https://yt3.googleusercontent.com/yTBoZe8bA8J09eOyHDqwTw2y7Kzmk4aFSq6pWQ1GfqMuILeXErkti5lSJp4xCeLfl_HkLSsWbx4=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCmla4OjsAqsyAbjS5XYqfPg", - name = "Easy Sounds Relaxation", - handle = "@EasySoundsRelaxationChannel", - description = "Easy sounds for relaxation and sleep", - thumbnail = "https://yt3.googleusercontent.com/ocbMQOTQhM_FCH_vd0Ji6TWCCMPLIaIYoy-dLXDZ6H5HveQ5WB2G6Y-cktcgQK364oNY06P_=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCM4svmA3yxv9JxTE44R3YPw", - name = "Relax Corner", - handle = "@RelaxCorner", - description = "Relax corner for peace and calm", - thumbnail = "https://yt3.googleusercontent.com/VN4eW1j9QKPmCqAXX5v-8xJ4uyAz_oOb1V_cAXxSJ-d9TOh0Gi93jm6aasrNBoB9gr36O99cXA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UC2c1kbLVZXj2Ilmg8fieRGw", - name = "Inner Healing Sleep Sounds", - handle = "@InnerHealingSleepSounds", - description = "Inner healing sleep sounds for rest", - thumbnail = "https://yt3.googleusercontent.com/f4aKmJsc88nqcML2CY5PTr_o5AW5jO_f4ROuY_wurp1ONoCSzFeJ3WkUoZc6NMPbofWxbkEmmA=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCa6DBGeztqfXOwcpUnk0Ccg", - name = "Pure Sleeping Vibes", - handle = "@puresleepingvibes", - description = "Pure sleeping vibes for deep rest", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_nC-PFpKHQIRsb0NDS7C1xtBB3FPv6k7a9MuFC3jT4zJQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCpIQRQM9-UII2sguAkImNQg", - name = "Relaxing Sleep Music", - handle = "@RelaxingSleepMusic95", - description = "Relaxing sleep music for rest", - thumbnail = "https://yt3.googleusercontent.com/8sWwFnogiooKVqZ5kr2X9zRyowBMfc0MV80yKHL3cswPsCoOG5V7-PE21Ff7_guWqtSNg7Qlp0M=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCjzHeG1KWoonmf9d5KBvSiw", - name = "Soothing Relaxation", - handle = "@SoothingRelaxation", - description = "Soothing relaxation music for peace", - thumbnail = "https://yt3.googleusercontent.com/iXm_TniUe0iI9JPfyqk-FKc4Wllsq54HK3nNQjwdY_eMNBRUTJHvl8CXooNgrejaSV7m68vR=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCX9_ziW7WnghYgwZJ7-_vDQ", - name = "Sleep Soundly", - handle = "@SleepSoundly", - description = "Sleep soundly with peaceful music", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_nfwL5491BwUS8w2gbM7OfI-qB0DatYtrn60XKBXK0=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCvlbxQUaS6a-i9Ek5T-aIFw", - name = "Sleeping Forest", - handle = "@sleepingforestant", - description = "Sleeping forest ambient sounds", - thumbnail = "https://yt3.googleusercontent.com/521vxJjHI3y0GoPXk0A1WY3VRK--t1qaMSTZ6LxRVUGWhVF3a57_IqRrf_Bmceqz8VVrVIKr8A=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCJuMbdKSMThk2RpALASyXVQ", - name = "Calmed By Nature", - handle = "@CalmedByNature", - description = "Calmed by nature sounds and music", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_n0SkOMDWP9_2oAmNP5uX8ySoNmHdIgJHGyRZpvcf1oNbY=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCCyDdKgBt6wTfl5ia-AKkcg", - name = "Calm Horizon", - handle = "@Calm_Horizon_21", - description = "Calm horizon ambient and meditation", - thumbnail = "https://yt3.googleusercontent.com/fwRxvOHVqdQf1q12Nk4k8M9rOpXjyBT84NzKz9WcYJ1AFAlacl2011PhFdan8nbWtGraYkqIFQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCSXm6c-n6lsjtyjvdD0bFVw", - name = "Liquicity", - handle = "@Liquicity", - description = "Liquicity drum and bass radio", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_keH0Kk1YqYA45rjktVPJ6nU3XBnrztNjqxqzLr9ncschM=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCYQHXu4Ea4NTvBggGHT7cOQ", - name = "DNB Allstars", - handle = "@dnballstars", - description = "DNB Allstars drum and bass", - thumbnail = "https://yt3.googleusercontent.com/iMQKLz1Nm2ROm-ZbK2DcyJZowa7Qr1UcvgpPg90y_7DOJCky80z48dShug4hr6BONX9Wc8_iGvQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCpYkkFDnvHka9CBuwxPpqXw", - name = "UKF On Air", - handle = "@UKFOnAir", - description = "UKF On Air drum and bass", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_lGCVrCmMMqmXGZ7s1R3D9iWflI-_G0yz84W62tf_tpOdE=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCGOHFpsA_rZ3zk2VdQCU2qQ", - name = "Fear N Loathing", - handle = "@FearNLoathing", - description = "Fear N Loathing drum and bass", - thumbnail = "https://yt3.googleusercontent.com/ytc/AIdro_mKeV20onxhR93_VxB-e9K2JRTmqxgE802_2G0Az8aRSoI=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCKWPSyqIez4GueaXImPnecA", - name = "Dub Zone", - handle = "@dub_zone5", - description = "Dub Zone dub and bass music", - thumbnail = "https://yt3.googleusercontent.com/HcRo07DWZE7aTB9KGZKPO7EMm2-HljWj-L3c3RhcogvhhOD0MXXu6CNVgkDF5lWQB-qOWO7XIaQ=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCerYu_6oSsXQ8KloFJ9_SYQ", - name = "Mutuca Dubz", - handle = "@mutucadubz", - description = "Mutuca Dubz dub and bass", - thumbnail = "https://yt3.googleusercontent.com/f4p9obHQV1yL84ZFEsJU4CLIzEABhbgipjwI31H1hMm6S66L5rlfx3U6U4zNxCIyBHcvg18Rtp0=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCuoFay4RgXTstakS4HLrybg", - name = "Tribal Need", - handle = "@tribalneed", - description = "Tribal Need dubstep and bass", - thumbnail = "https://yt3.googleusercontent.com/-jpP1u6-MwGizudLxaW8moYdsyQoqybUHkF0Sx6n2LJ_KHXQChSFihsxDPad2w-vHMhqba2Zzmk=s900-c-k-c0x00ffffff-no-rj" - ), - Channel( - id = "UCfRsou5aIVXUAl4-F5xC93w", - name = "Sphere Of Hip Hop", - handle = "@sphereofhiphop", - description = "Sphere Of Hip Hop lofi and chill", - thumbnail = "https://yt3.googleusercontent.com/b8I2f0ojdr2Ay-GL_fBrhHp3sON_SP800fPgFYBtzl2y2PEAN7FiMoKtIe96bsC1MGJAZQg3hA=s900-c-k-c0x00ffffff-no-rj" - ) -) \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/data/models/Channel.kt b/android/app/src/main/java/com/lofiradio/data/models/Channel.kt deleted file mode 100644 index c2cd3c0..0000000 --- a/android/app/src/main/java/com/lofiradio/data/models/Channel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.lofiradio.data.models - -data class Channel( - val id: String, - val name: String, - val handle: String, - val description: String, - val thumbnail: String?, - val isLive: Boolean = false, - val videoId: String? = null, - val liveThumbnail: String? = null, - val isFavorite: Boolean = false -) \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/data/models/StreamInfo.kt b/android/app/src/main/java/com/lofiradio/data/models/StreamInfo.kt deleted file mode 100644 index fee3abd..0000000 --- a/android/app/src/main/java/com/lofiradio/data/models/StreamInfo.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.lofiradio.data.models - -data class StreamInfo( - val videoId: String, - val url: String, - val streamType: String, - val title: String = "", - val channel: String = "", - val duration: Long? = null, - val isLive: Boolean = false -) - -data class ChannelLiveResponse( - val channelId: String, - val name: String, - val isLive: Boolean, - val videoId: String?, - val thumbnail: String? -) - -data class ChannelListResponse( - val channels: List -) - -data class ServerChannel( - val id: String, - val name: String, - val handle: String, - val description: String, - val isLive: Boolean, - val videoId: String?, - val thumbnail: String? -) \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/network/ServerApi.kt b/android/app/src/main/java/com/lofiradio/network/ServerApi.kt deleted file mode 100644 index 75fcaf7..0000000 --- a/android/app/src/main/java/com/lofiradio/network/ServerApi.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.lofiradio.network - -import com.lofiradio.data.models.ChannelListResponse -import com.lofiradio.data.models.ChannelLiveResponse -import com.lofiradio.data.models.StreamInfo -import com.lofiradio.util.Preferences -import com.google.gson.Gson -import okhttp3.Call -import okhttp3.OkHttpClient -import okhttp3.Request -import java.util.concurrent.TimeUnit - -class ServerApi(private val context: android.content.Context) { - private val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .followRedirects(true) - .build() - - private val gson = Gson() - private val userAgent = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" - - fun getServerUrl(): String { - return Preferences.getServerUrl(context) - } - - suspend fun getChannels(): List? { - val response = get("$getServerUrl()/api/channels") - return try { - val json = com.google.gson.JsonParser.parseString(response) - val channels = if (json.isJsonArray) json.getAsJsonArray() else json.getAsJsonObject().getAsJsonArray("channels") - channels.map { item -> - gson.fromJson(item.asString, com.lofiradio.data.models.ServerChannel::class.java) - } - } catch (e: Exception) { - null - } - } - - suspend fun checkChannelLive(channelId: String): ChannelLiveResponse? { - val response = get("$getServerUrl()/api/channels/$channelId/live") ?: return null - return gson.fromJson(response, ChannelLiveResponse::class.java) - } - - suspend fun getStream(videoId: String): StreamInfo? { - val response = get("$getServerUrl()/api/stream/$videoId") ?: return null - return gson.fromJson(response, StreamInfo::class.java) - } - - suspend fun getNowPlaying(): StreamInfo? { - val response = get("$getServerUrl()/api/now-playing") ?: return null - val json = com.google.gson.JsonParser.parseString(response).asJsonObject - val videoId = json.get("videoId")?.asString - if (videoId.isNullOrEmpty() || json.get("url")?.asString.isNullOrEmpty()) { - return null - } - return gson.fromJson(response, StreamInfo::class.java) - } - - private suspend fun get(url: String): String? { - val request = Request.Builder() - .url(url) - .header("User-Agent", userAgent) - .build() - - return with kotlinx.coroutines.suspendCancellableCoroutine { continuation -> - val call = client.newCall(request) - call.enqueue(object : okhttp3.Callback { - override fun onFailure(call: Call, e: java.io.IOException) { - continuation.resumeWith(Result.failure(e)) - } - - override fun onResponse(call: Call, response: okhttp3.Response) { - if (response.isSuccessful) { - continuation.resumeWith(Result.success(response.body?.string())) - } else { - response.body?.close() - continuation.resumeWith(Result.failure(Exception("HTTP ${response.code}"))) - } - } - }) - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt b/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt deleted file mode 100644 index bff491b..0000000 --- a/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt +++ /dev/null @@ -1,283 +0,0 @@ -package com.lofiradio.network - -import com.lofiradio.data.models.StreamInfo -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import okhttp3.Call -import okhttp3.OkHttpClient -import okhttp3.Request -import java.net.URL -import java.util.concurrent.TimeUnit - -object YouTubeExtractor { - private val client = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .connectionPool(okhttp3.ConnectionPool(10, 30, TimeUnit.SECONDS)) - .build() - - private val gson = Gson() - private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" - - fun extractAudioStream(videoId: String): StreamInfo? { - val watchUrl = "https://www.youtube.com/watch?v=$videoId" - - val watchPage = fetchPage(watchUrl) ?: return null - - val playerEmbedUrl = extractPlayerEmbedUrl(watchPage) ?: return null - val playerResponse = fetchPlayerResponse(videoId, playerEmbedUrl) ?: return null - - val formats = playerResponse.getAsJsonArray("streamingData")?.let { streamingData -> - parseFormats(streamingData) - } ?: emptyList() - - val hlsUrl = findHlsUrl(formats) - if (hlsUrl != null) { - return StreamInfo( - videoId = videoId, - url = hlsUrl, - streamType = "hls", - title = playerResponse.getAsJsonObject("videoDetails")?.get("title")?.asString ?: "", - isLive = playerResponse.getAsJsonObject("videoDetails")?.get("isLive")?.asBoolean ?: false - ) - } - - val directUrl = findDirectAudioUrl(formats) - if (directUrl != null) { - return StreamInfo( - videoId = videoId, - url = directUrl, - streamType = "direct", - title = playerResponse.getAsJsonObject("videoDetails")?.get("title")?.asString ?: "", - isLive = playerResponse.getAsJsonObject("videoDetails")?.get("isLive")?.asBoolean ?: false - ) - } - - return null - } - - fun discoverVideo(channelId: String, handle: String, apiKey: String?): StreamInfo? { - val videoId = if (!apiKey.isNullOrBlank()) { - discoverVideoViaApi(channelId, handle, apiKey) - } else { - discoverVideoViaScraping(channelId, handle) - } ?: return null - - return extractAudioStream(videoId) - } - - private fun fetchPage(url: String): String? { - val request = Request.Builder() - .url(url) - .header("User-Agent", USER_AGENT) - .header("Referer", "https://www.youtube.com/") - .build() - - return try { - client.newCall(request).execute().body?.string() - } catch (e: Exception) { - null - } - } - - private fun extractPlayerEmbedUrl(pageContent: String): String? { - val patterns = listOf( - """player(?:"\s*\+\s*"([^"]+))""", - """player_-src=([^&\s]+)""", - """("player_\\?load=\\?device=desktop&load=player&url=)([^"&\\s]+)""" - ) - - for (pattern in patterns) { - val regex = pattern.toRegex() - val match = regex.find(pageContent) - if (match != null && match.groupValues.size > 1) { - var baseUrl = match.groupValues.last { it.isNotEmpty() } - if (!baseUrl.startsWith("http")) { - baseUrl = "https://www.youtube.com/$baseUrl" - } - return baseUrl - } - } - return null - } - - private fun fetchPlayerResponse(videoId: String, playerEmbedUrl: String): JsonObject? { - val apiBaseUrl = extractApiBaseUrl(playerEmbedUrl) ?: "https://www.youtube.com" - val apiUrl = "$apiBaseUrl/youtubei/v1/player?key=AIzaSyY0J6BpnJtNTn7ZC342pC87M10zKxIzY8Y" - - val payload = """ - { - "videoId": "$videoId", - "context": { - "client": { - "clientName": "ANDROID", - "clientVersion": "18.19.37", - "deviceMake": "Google", - "deviceModel": "Pixel", - "osName": "Android", - "osVersion": "14" - } - } - } - """.trimIndent() - - val request = Request.Builder() - .url(apiUrl) - .header("User-Agent", USER_AGENT) - .header("Content-Type", "application/json") - .post(payload.toByteArray()) - .build() - - return try { - val response = client.newCall(request).execute() - val body = response.body?.string() - if (!body.isNullOrBlank()) { - JsonParser.parseString(body).asJsonObject - } else { - null - } - } catch (e: Exception) { - null - } - } - - private fun extractApiBaseUrl(playerUrl: String): String? { - return try { - val url = URL(playerUrl) - "${url.protocol}://${url.host}" - } catch (e: Exception) { - null - } - } - - private fun parseFormats(streamingData: com.google.gson.JsonElement): List> { - val formats = mutableListOf>() - - streamingData.asJsonObject.entrySet().forEach { (key, value) -> - if (key == "adaptiveFormats" || key == "formats") { - value.asJsonArray.forEach { fmt -> - val obj = fmt.asJsonObject - val url = obj.get("url")?.asString - val itag = obj.get("itag")?.asString ?: "" - val mimeType = obj.get("mimeType")?.asString ?: "" - val acodec = obj.get("audioCodec")?.asString ?: "none" - val vcodec = obj.get("videoCodec")?.asString ?: "none" - val formatNote = obj.get("formatShort")?.asString ?: "" - - if (url != null && acodec != "none") { - formats.add(url to mimeType) - } - } - } - } - - return formats - } - - private fun findHlsUrl(formats: List>): String? { - for ((url, mimeType) in formats) { - if (mimeType.contains("mp4") && mimeType.contains("codecs") && mimeType.contains("audio")) { - return url - } - } - return formats.firstOrNull { (url, mimeType) -> - mimeType.contains("audio") - }?.first - } - - private fun findDirectAudioUrl(formats: List>): String? { - for ((url, mimeType) in formats) { - if (mimeType.contains("audio")) { - return url - } - } - return formats.firstOrNull?.first - } - - private fun discoverVideoViaApi(channelId: String, handle: String, apiKey: String): String? { - val chart = "latest" - val maxResults = 5 - val part = "snippet" - val order = "date" - - val url = buildString { - append("https://www.googleapis.com/youtube/v3/search?") - append("part=$part&") - append("channelId=$channelId&") - append("type=video&") - append("order=$order&") - append("maxResults=$maxResults&") - append("key=$apiKey") - } - - val request = Request.Builder() - .url(url) - .header("User-Agent", USER_AGENT) - .build() - - return try { - val response = client.newCall(request).execute() - val body = response.body?.string() - if (!body.isNullOrBlank()) { - val json = JsonParser.parseString(body).asJsonObject - val items = json.getAsJsonArray("items") - for (item in items) { - val snippet = item.asJsonObject.getAsJsonObject("snippet") - val liveSnippet = item.asJsonObject.getAsJsonObject("liveBroadcastSnippet") - if (liveSnippet != null) { - return item.asJsonObject.getAsJsonObject("id").get("videoId")?.asString - } - } - items.firstOrNull()?.asJsonObject?.getAsJsonObject("id")?.get("videoId")?.asString - } else { - null - } - } catch (e: Exception) { - null - } - } - - private fun discoverVideoViaScraping(channelId: String, handle: String): String? { - val urlsToTry = mutableListOf() - - if (handle.startsWith("@")) { - urlsToTry.add("https://www.youtube.com/$handle/live") - urlsToTry.add("https://www.youtube.com/$handle/streams") - urlsToTry.add("https://www.youtube.com/$handle/videos") - } - - urlsToTry.add("https://www.youtube.com/channel/$channelId/live") - urlsToTry.add("https://www.youtube.com/channel/$channelId/streams") - urlsToTry.add("https://www.youtube.com/channel/$channelId/videos") - - for (url in urlsToTry) { - val pageContent = fetchPage(url) ?: continue - - val videoId = extractVideoIdFromPage(pageContent) - if (videoId != null) { - return videoId - } - } - - return null - } - - private fun extractVideoIdFromPage(html: String): String? { - val patterns = listOf( - """/watch\?v=([a-zA-Z0-9_-]{11})""", - """/live/([a-zA-Z0-9_-]{11})""", - """\"videoId\":\"([a-zA-Z0-9_-]{11})\"""" - ) - - for (pattern in patterns) { - val regex = pattern.toRegex() - val match = regex.find(html) - if (match != null) { - return match.groupValues[1] - } - } - - return null - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt b/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt deleted file mode 100644 index 20fb494..0000000 --- a/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.lofiradio.player - -import android.content.Context -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.audio.AudioAttributes -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.hls.HlsMediaSource -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.exoplayer.source.MediaSource -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.withLock - -class AudioPlayer(private val context: Context) : Player.Listener { - private val exoPlayer: ExoPlayer - private val mutex = Mutex() - private var currentVideoId: String? = null - private var currentStreamUrl: String? = null - private var currentStreamType: String? = null - - val isPlaying: Boolean get() = exoPlayer.isPlaying - val playbackState: Int get() = exoPlayer.playbackState - val duration: Long get() = exoPlayer.duration - val currentPosition: Long get() = exoPlayer.currentPosition - val volume: Float get() = exoPlayer.volume - - var onStateChanged: (Int) -> Boolean = { false } - var onError: (String) -> Unit = { } - - init { - exoPlayer = ExoPlayer.Builder(context) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_LOCAL) - .build() - - exoPlayer.addListener(this) - } - - suspend fun play(videoId: String, url: String, streamType: String) = mutex.withLock { - currentVideoId = videoId - currentStreamUrl = url - currentStreamType = streamType - - exoPlayer.stop() - val mediaSource = createMediaSource(url, streamType) - exoPlayer.setMediaSource(mediaSource) - exoPlayer.prepare() - exoPlayer.play() - } - - suspend fun pause() = mutex.withLock { - exoPlayer.pause() - } - - suspend fun resume() = mutex.withLock { - exoPlayer.play() - } - - suspend fun stop() = mutex.withLock { - exoPlayer.stop() - currentVideoId = null - currentStreamUrl = null - currentStreamType = null - } - - suspend fun setVolume(volume: Float) = mutex.withLock { - exoPlayer.setVolume(volume) - } - - suspend fun seekTo(position: Long) = mutex.withLock { - exoPlayer.seekTo(position) - } - - fun getCurrentVideoId(): String? = currentVideoId - - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - Player.STATE_ENDED -> { - onError("Stream ended") - } - } - onStateChanged(playbackState) - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - onStateChanged(Player.STATE_READY) - } else { - onStateChanged(Player.STATE_IDLE) - } - } - - override fun onPlayerError(error: androidx.media3.common.ExoPlaybackException) { - onError("Player error: ${error.message}") - } - - fun release() { - exoPlayer.removeListener(this) - exoPlayer.release() - } - - private fun createMediaSource(url: String, streamType: String): MediaSource { - val httpDataSource = DefaultHttpDataSource.Builder() - .setUserAgent("Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36") - .setAllowCrossProtocolRedirects(true) - .build() - - val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSource) - - return when { - streamType == "hls" || url.contains(".m3u8") -> { - HlsMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(url)) - } - else -> { - exoPlayer.createMediaSource(MediaItem.fromUri(url)) - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt b/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt deleted file mode 100644 index 88926a1..0000000 --- a/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.lofiradio.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import coil.load -import com.lofiradio.R -import com.lofiradio.databinding.FragmentChannelsBinding -import com.lofiradio.databinding.ItemChannelBinding -import com.lofiradio.data.models.Channel - -class ChannelListFragment : Fragment() { - private var _binding: FragmentChannelsBinding? = null - private val binding get() = _binding!! - private val viewModel: LofiViewModel by viewModels() - - private lateinit var adapter: ChannelAdapter - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentChannelsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - adapter = ChannelAdapter(viewModel) - setupRecyclerView() - setupTabs() - setupSwipeRefresh() - setupFab() - observeViewModel() - } - - private fun setupRecyclerView() { - binding.channelList.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = this@ChannelListFragment.adapter - } - } - - private fun setupTabs() { - binding.tabs.addTab(binding.tabs.newTab().setText("All")) - binding.tabs.addTab(binding.tabs.newTab().setText("Live")) - binding.tabs.addTab(binding.tabs.newTab().setText("Favorites")) - - binding.tabs.addOnTabSelectedListener(object : com.google.android.material.tabs.TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab?) { - when (tab?.position) { - 0 -> viewModel.setTab(LofiViewModel.Tab.ALL) - 1 -> viewModel.setTab(LofiViewModel.Tab.LIVE) - 2 -> viewModel.setTab(LofiViewModel.Tab.FAVORITES) - } - updateList() - } - - override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {} - override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {} - }) - } - - private fun setupSwipeRefresh() { - binding.swipeRefresh.setOnRefreshListener { - viewModel.discoverAllChannels() - } - } - - private fun setupFab() { - binding.fabDiscover.setOnClickListener { - viewModel.discoverAllChannels() - } - } - - private fun observeViewModel() { - viewModel.channels.observe(viewLifecycleOwner) { - updateList() - } - - viewModel.isDiscovering.observe(viewLifecycleOwner) { - binding.fabDiscover.isEnabled = !it - binding.swipeRefresh.isRefreshing = it - if (it) { - Toast.makeText(requireContext(), "Discovering live channels...", Toast.LENGTH_SHORT).show() - } - } - - viewModel.error.observe(viewLifecycleOwner) { - it?.let { - Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show() - } - } - } - - private fun updateList() { - val visible = viewModel.getVisibleChannels() - adapter.submitList(visible) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} - -class ChannelAdapter( - private val viewModel: LofiViewModel -) : ListAdapter(ChannelDiffCallback()) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { - val binding = ItemChannelBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return ChannelViewHolder(binding, viewModel) - } - - override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) { - holder.bind(getItem(position)) - } -} - -class ChannelDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Channel, newItem: Channel) = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Channel, newItem: Channel) = oldItem == newItem -} - -class ChannelViewHolder( - private val binding: ItemChannelBinding, - private val viewModel: LofiViewModel -) : RecyclerView.ViewHolder(binding.root) { - fun bind(channel: Channel) { - binding.channelName.text = channel.name - binding.channelDescription.text = channel.description - - val thumbUrl = channel.liveThumbnail ?: channel.thumbnail - if (!thumbUrl.isNullOrBlank()) { - binding.channelThumbnail.load(thumbUrl) { - crossfade(true) - placeholder(android.R.color.darker_gray) - } - } - - binding.channelStatus.visibility = if (channel.isLive) View.VISIBLE else View.GONE - binding.channelStatus.text = if (channel.isLive) "● LIVE" else "" - - binding.btnFavorite.setImageResource( - if (channel.isFavorite) android.R.drawable.btn_star_big_on else android.R.drawable.btn_star_big_off - ) - - binding.btnFavorite.setOnClickListener { - viewModel.toggleFavorite(channel.id) - } - - binding.btnPlay.isEnabled = channel.isLive && !channel.videoId.isNullOrEmpty() - binding.btnPlay.setOnClickListener { - val activity = binding.root.context - if (activity is MainActivity) { - activity.supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, PlayerFragment()) - .commit() - val nav = activity.findViewById(R.id.bottom_navigation) - nav.selectedItemId = R.id.navigation_player - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt b/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt deleted file mode 100644 index 0386451..0000000 --- a/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt +++ /dev/null @@ -1,289 +0,0 @@ -package com.lofiradio.ui - -import android.content.Context -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.lofiradio.data.CHANNELS -import com.lofiradio.data.models.Channel -import com.lofiradio.data.models.StreamInfo -import com.lofiradio.network.ServerApi -import com.lofiradio.network.YouTubeExtractor -import com.lofiradio.player.AudioPlayer -import com.lofiradio.util.Preferences -import kotlinx.coroutines.* - -class LofiViewModel( - private val context: Context, - private val serverApi: ServerApi, - private val audioPlayer: AudioPlayer -) : ViewModel() { - - private val _channelList = mutableListOf() - private val _channels = androidx.lifecycle.MutableLiveData>(emptyList()) - val channels: androidx.lifecycle.LiveData> get() = _channels - - private fun notifyChannelsChanged() { - _channels.value = _channelList.toList() - } - - private val _currentChannel = androidx.lifecycle.MutableLiveData(null) - val currentChannel: androidx.lifecycle.LiveData get() = _currentChannel - - private val _playerState = androidx.lifecycle.MutableLiveData(PlayerState.IDLE) - val playerState: androidx.lifecycle.LiveData get() = _playerState - - private val _isLoading = androidx.lifecycle.MutableLiveData(false) - val isLoading: androidx.lifecycle.LiveData get() = _isLoading - - private val _error = androidx.lifecycle.MutableLiveData(null) - val error: androidx.lifecycle.LiveData get() = _error - - private val _isDiscovering = androidx.lifecycle.MutableLiveData(false) - val isDiscovering: androidx.lifecycle.LiveData get() = _isDiscovering - - private var currentTab: Tab = Tab.ALL - - enum class Tab { - ALL, - FAVORITES, - LIVE - } - - enum class PlayerState { - IDLE, - PLAYING, - PAUSED, - BUFFERING, - ERROR, - ENDED - } - - init { - _channelList.addAll(CHANNELS.map { it.copy() }) - notifyChannelsChanged() - audioPlayer.onStateChanged = { state -> - when (state) { - androidx.media3.common.Player.STATE_READY -> { - if (audioPlayer.isPlaying) { - _playerState.postValue(PlayerState.PLAYING) - } else { - _playerState.postValue(PlayerState.PAUSED) - } - } - androidx.media3.common.Player.STATE_BUFFERING -> { - _playerState.postValue(PlayerState.BUFFERING) - } - androidx.media3.common.Player.STATE_ENDED -> { - _playerState.postValue(PlayerState.ENDED) - if (Preferences.isAutoAdvance(context)) { - playNext() - } - } - androidx.media3.common.Player.STATE_IDLE -> { - _playerState.postValue(PlayerState.IDLE) - } - } - false - } - - audioPlayer.onError = { message -> - _error.postValue(message) - _playerState.postValue(PlayerState.ERROR) - } - } - - fun getVisibleChannels(): List { - return when (currentTab) { - Tab.ALL -> _channelList - Tab.FAVORITES -> _channelList.filter { Preferences.isFavorite(context, it.id) } - Tab.LIVE -> _channelList.filter { it.isLive } - } - } - - fun setTab(tab: Tab) { - currentTab = tab - } - - fun toggleFavorite(channelId: String) { - Preferences.toggleFavorite(context, channelId) - val idx = _channelList.indexOfFirst { it.id == channelId } - if (idx != -1) { - val fav = Preferences.isFavorite(context, channelId) - _channelList[idx] = _channelList[idx].copy(isFavorite = fav) - notifyChannelsChanged() - } - } - - fun playChannel(channel: Channel) { - _error.value = null - if (channel.videoId.isNullOrBlank()) { - _error.value = "Channel is not currently live" - _playerState.value = PlayerState.ERROR - return - } - - when (Preferences.getStreamingMode(context)) { - Preferences.StreamingMode.SERVER -> { - playViaServer(channel) - } - Preferences.StreamingMode.DIRECT -> { - playViaDirect(channel) - } - } - } - - private fun playViaServer(channel: Channel) { - _isLoading.value = true - viewModelScope.launch { - try { - val stream = serverApi.getStream(channel.videoId!!) - if (stream != null) { - audioPlayer.play(stream.videoId, stream.url, stream.streamType) - _currentChannel.value = channel - _playerState.value = PlayerState.PLAYING - } else { - _error.value = "Could not get stream from server" - _playerState.value = PlayerState.ERROR - } - } catch (e: Exception) { - Log.e("LofiViewModel", "Server play error", e) - _error.value = "Server connection failed: ${e.message}" - _playerState.value = PlayerState.ERROR - } finally { - _isLoading.value = false - } - } - } - - private fun playViaDirect(channel: Channel) { - _isLoading.value = true - viewModelScope.launch { - try { - val stream = withContext(Dispatchers.IO) { - YouTubeExtractor.extractAudioStream(channel.videoId!!) - } - if (stream != null) { - audioPlayer.play(stream.videoId, stream.url, stream.streamType) - _currentChannel.value = channel - _playerState.value = PlayerState.PLAYING - } else { - _error.value = "Could not extract stream from YouTube" - _playerState.value = PlayerState.ERROR - } - } catch (e: Exception) { - Log.e("LofiViewModel", "Direct play error", e) - _error.value = "Extraction failed: ${e.message}" - _playerState.value = PlayerState.ERROR - } finally { - _isLoading.value = false - } - } - } - - fun playNext() { - val current = _currentChannel.value ?: return - val visible = getVisibleChannels() - val currentIndex = visible.indexOfFirst { it.id == current.id } - val nextIndex = (currentIndex + 1) % visible.size - val nextChannel = visible[nextIndex] - if (nextChannel.isLive) { - playChannel(nextChannel) - } - } - - fun playPrevious() { - val current = _currentChannel.value ?: return - val visible = getVisibleChannels() - val currentIndex = visible.indexOfFirst { it.id == current.id } - val prevIndex = if (currentIndex == 0) visible.size - 1 else currentIndex - 1 - val prevChannel = visible[prevIndex] - if (prevChannel.isLive) { - playChannel(prevChannel) - } - } - - fun pausePlayback() { - audioPlayer.pause() - } - - fun resumePlayback() { - audioPlayer.resume() - } - - fun stopPlayback() { - audioPlayer.stop() - _currentChannel.value = null - _playerState.value = PlayerState.IDLE - } - - fun setVolume(volume: Float) { - audioPlayer.setVolume(volume) - } - - fun retry() { - _error.value = null - val channel = _currentChannel.value - if (channel != null) { - playChannel(channel) - } - } - - fun discoverAllChannels() { - _isDiscovering.value = true - viewModelScope.launch { - val mode = Preferences.getStreamingMode(context) - val apiKey = Preferences.getYouTubeApiKey(context) - val updated = mutableListOf() - - for (channel in _channelList.toList()) { - try { - val newChannel = when (mode) { - Preferences.StreamingMode.SERVER -> { - withContext(Dispatchers.IO) { - serverApi.checkChannelLive(channel.id) - } - }.let { live -> - channel.copy( - isLive = live?.isLive ?: false, - videoId = live?.videoId, - liveThumbnail = live?.thumbnail - ) - } - Preferences.StreamingMode.DIRECT -> { - withContext(Dispatchers.IO) { - YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey) - } - }.let { stream -> - channel.copy( - isLive = stream != null, - videoId = stream?.videoId - ) - } - } - updated.add(newChannel) - } catch (e: Exception) { - Log.e("LofiViewModel", "Discovery error for ${channel.name}", e) - updated.add(channel) - } - delay(500) - } - _channelList.clear() - _channelList.addAll(updated) - notifyChannelsChanged() - _isDiscovering.value = false - } - } - - fun getStreamingMode(): Preferences.StreamingMode { - return Preferences.getStreamingMode(context) - } - - fun releaseAudioPlayer() { - audioPlayer.release() - } - - override fun onCleared() { - } -} - diff --git a/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt b/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt deleted file mode 100644 index a1b1441..0000000 --- a/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.lofiradio.ui - -import android.os.Bundle -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import com.lofiradio.R -import com.lofiradio.databinding.ActivityMainBinding -import com.lofiradio.network.ServerApi -import com.lofiradio.player.AudioPlayer -import com.lofiradio.util.Preferences - -class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding - private val viewModel: LofiViewModel by viewModels { - object : androidx.lifecycle.ViewModelProvider.Factory { - override fun create(modelClass: kotlin.Class): T { - @Suppress("UNCHECKED_CAST") - return LofiViewModel(applicationContext, ServerApi(applicationContext), AudioPlayer(applicationContext)) as T - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupNavigation() - - if (savedInstanceState == null) { - supportFragmentManager.beginTransaction() - .add(R.id.fragment_container, ChannelListFragment(), "channels") - .commit() - binding.bottomNavigation.selectedItemId = R.id.navigation_channels - } - - viewModel.discoverAllChannels() - } - - private fun setupNavigation() { - binding.bottomNavigation.setOnItemSelectedListener { item -> - when (item.itemId) { - R.id.navigation_channels -> { - showFragment(ChannelListFragment(), "channels") - true - } - R.id.navigation_player -> { - showFragment(PlayerFragment(), "player") - true - } - R.id.navigation_settings -> { - showFragment(SettingsFragment(), "settings") - true - } - else -> false - } - } - } - - private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) { - val existing = supportFragmentManager.findFragmentByTag(tag) - supportFragmentManager.beginTransaction().apply { - if (existing != null) { - hide(existing) - show(existing) - } else { - add(R.id.fragment_container, fragment, tag) - } - }.commit() - } - - override fun onPause() { - super.onPause() - } - - override fun onResume() { - super.onResume() - } - - override fun onDestroy() { - super.onDestroy() - viewModel.releaseAudioPlayer() - } -} diff --git a/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt b/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt deleted file mode 100644 index 6c0e616..0000000 --- a/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.lofiradio.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import coil.load -import com.lofiradio.R -import com.lofiradio.databinding.FragmentPlayerBinding -import com.lofiradio.player.AudioPlayer -import com.lofiradio.util.Preferences - -class PlayerFragment : Fragment() { - private var _binding: FragmentPlayerBinding? = null - private val binding get() = _binding!! - private val viewModel: LofiViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentPlayerBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupControls() - observeViewModel() - } - - private fun setupControls() { - binding.btnPlayPause.setOnClickListener { - when (viewModel.playerState.value) { - LofiViewModel.PlayerState.PLAYING -> viewModel.pausePlayback() - LofiViewModel.PlayerState.PAUSED -> viewModel.resumePlayback() - LofiViewModel.PlayerState.IDLE -> { - val channel = viewModel.currentChannel.value - if (channel != null) { - viewModel.playChannel(channel) - } - } - else -> { - val channel = viewModel.currentChannel.value - if (channel != null) { - viewModel.playChannel(channel) - } - } - } - } - - binding.btnNext.setOnClickListener { - viewModel.playNext() - } - - binding.btnPrevious.setOnClickListener { - viewModel.playPrevious() - } - - binding.volumeSlider.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: android.widget.SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) { - viewModel.setVolume(progress / 100f) - binding.volumeValue.text = "$progress%" - } - } - override fun onStartTrackingTouch(seekBar: android.widget.SeekBar?) {} - override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) {} - }) - - binding.btnRetry.setOnClickListener { - viewModel.retry() - } - } - - private fun observeViewModel() { - viewModel.currentChannel.observe(viewLifecycleOwner) { channel -> - channel?.let { - binding.playerChannelName.text = it.name - binding.playerVideoTitle.text = it.description - val thumbUrl = it.liveThumbnail ?: it.thumbnail - if (!thumbUrl.isNullOrBlank()) { - binding.playerThumbnail.load(thumbUrl) { - crossfade(true) - } - } - binding.playerMode.text = when (viewModel.getStreamingMode()) { - Preferences.StreamingMode.SERVER -> "Server Mode" - Preferences.StreamingMode.DIRECT -> "Direct Mode" - } - } ?: run { - binding.playerChannelName.text = "No Channel Selected" - binding.playerVideoTitle.text = "" - binding.playerMode.text = "" - } - } - - viewModel.playerState.observe(viewLifecycleOwner) { state -> - updatePlayerUi(state) - } - - viewModel.isLoading.observe(viewLifecycleOwner) { loading -> - if (loading) { - binding.playerState.text = "Loading stream..." - binding.playerProgress.isIndeterminate = true - } else { - binding.playerProgress.isIndeterminate = false - } - } - - viewModel.error.observe(viewLifecycleOwner) { error -> - if (error != null) { - binding.errorContainer.visibility = View.VISIBLE - binding.errorMessage.text = error - } else { - binding.errorContainer.visibility = View.GONE - } - } - } - - private fun updatePlayerUi(state: LofiViewModel.PlayerState) { - binding.playerState.text = when (state) { - LofiViewModel.PlayerState.IDLE -> "No stream playing" - LofiViewModel.PlayerState.PLAYING -> "Playing" - LofiViewModel.PlayerState.PAUSED -> "Paused" - LofiViewModel.PlayerState.BUFFERING -> "Buffering..." - LofiViewModel.PlayerState.ERROR -> "Error" - LofiViewModel.PlayerState.ENDED -> "Stream ended" - } - - binding.btnPlayPause.setImageResource( - when (state) { - LofiViewModel.PlayerState.PLAYING -> android.R.drawable.ic_media_pause - else -> android.R.drawable.ic_btn_now_playing - } - ) - - binding.playerProgress.visibility = if (state == LofiViewModel.PlayerState.BUFFERING) { - View.VISIBLE - } else { - View.INVISIBLE - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/ui/SettingsFragment.kt b/android/app/src/main/java/com/lofiradio/ui/SettingsFragment.kt deleted file mode 100644 index 9e56420..0000000 --- a/android/app/src/main/java/com/lofiradio/ui/SettingsFragment.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.lofiradio.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import com.lofiradio.R -import com.lofiradio.databinding.FragmentSettingsBinding -import com.lofiradio.util.Preferences - -class SettingsFragment : Fragment() { - private var _binding: FragmentSettingsBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - loadSettings() - setupStreamingMode() - setupServerUrl() - setupYouTubeApiKey() - setupAutoAdvance() - } - - private fun loadSettings() { - val mode = Preferences.getStreamingMode(requireContext()) - if (mode == Preferences.StreamingMode.DIRECT) { - binding.radioDirect.isChecked = true - } else { - binding.radioServer.isChecked = true - } - - binding.etServerUrl.setText(Preferences.getServerUrl(requireContext())) - binding.etYouTubeApiKey.setText(Preferences.getYouTubeApiKey(requireContext()) ?: "") - binding.switchAutoAdvance.isChecked = Preferences.isAutoAdvance(requireContext()) - - updateCardVisibility() - } - - private fun setupStreamingMode() { - binding.radioServer.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - Preferences.setStreamingMode(requireContext(), Preferences.StreamingMode.SERVER) - Toast.makeText(requireContext(), "Switched to Server Proxy mode", Toast.LENGTH_SHORT).show() - updateCardVisibility() - } - } - - binding.radioDirect.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - Preferences.setStreamingMode(requireContext(), Preferences.StreamingMode.DIRECT) - Toast.makeText(requireContext(), "Switched to Direct mode", Toast.LENGTH_SHORT).show() - updateCardVisibility() - } - } - } - - private fun setupServerUrl() { - binding.etServerUrl.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - val url = binding.etServerUrl.text.toString().trim() - if (url.isNotEmpty()) { - Preferences.setServerUrl(requireContext(), url) - } - } - } - } - - private fun setupYouTubeApiKey() { - binding.etYouTubeApiKey.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - val key = binding.etYouTubeApiKey.text.toString().trim() - Preferences.setYouTubeApiKey(requireContext(), key.ifBlank { null }) - if (key.isNotEmpty()) { - Toast.makeText(requireContext(), "YouTube API Key saved", Toast.LENGTH_SHORT).show() - } - } - } - } - - private fun setupAutoAdvance() { - binding.switchAutoAdvance.setOnCheckedChangeListener { _, isChecked -> - Preferences.setAutoAdvance(requireContext(), isChecked) - } - } - - private fun updateCardVisibility() { - val isServerMode = binding.radioServer.isChecked - binding.serverSettingsCard.visibility = if (isServerMode) View.VISIBLE else View.GONE - binding.youtubeApiCard.visibility = if (!isServerMode) View.VISIBLE else View.GONE - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/util/Preferences.kt b/android/app/src/main/java/com/lofiradio/util/Preferences.kt deleted file mode 100644 index 4585839..0000000 --- a/android/app/src/main/java/com/lofiradio/util/Preferences.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.lofiradio.util - -import android.content.Context -import androidx.preference.PreferenceManager - -object Preferences { - private const val KEY_STREAMING_MODE = "streaming_mode" - private const val KEY_SERVER_URL = "server_url" - private const val KEY_YOUTUBE_API_KEY = "youtube_api_key" - private const val KEY_AUTO_ADVANCE = "auto_advance" - private const val KEY_FAVORITES = "favorites" - - enum class StreamingMode { - SERVER, - DIRECT - } - - fun getStreamingMode(context: Context): StreamingMode { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return when (prefs.getString(KEY_STREAMING_MODE, "server")) { - "direct" -> StreamingMode.DIRECT - else -> StreamingMode.SERVER - } - } - - fun setStreamingMode(context: Context, mode: StreamingMode) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - prefs.edit().putString(KEY_STREAMING_MODE, mode.name.lowercase()).apply() - } - - fun getServerUrl(context: Context): String { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getString(KEY_SERVER_URL, "") ?: "" - } - - fun setServerUrl(context: Context, url: String) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - prefs.edit().putString(KEY_SERVER_URL, url).apply() - } - - fun getYouTubeApiKey(context: Context): String? { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getString(KEY_YOUTUBE_API_KEY, null) - } - - fun setYouTubeApiKey(context: Context, key: String?) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - prefs.edit().putString(KEY_YOUTUBE_API_KEY, key).apply() - } - - fun isAutoAdvance(context: Context): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getBoolean(KEY_AUTO_ADVANCE, false) - } - - fun setAutoAdvance(context: Context, enabled: Boolean) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - prefs.edit().putBoolean(KEY_AUTO_ADVANCE, enabled).apply() - } - - fun getFavorites(context: Context): Set { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet() - } - - fun toggleFavorite(context: Context, channelId: String): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val favorites = mutableSetOf(getFavorites(context)) - return if (favorites.contains(channelId)) { - favorites.remove(channelId) - prefs.edit().putStringSet(KEY_FAVORITES, favorites).apply() - true - } else { - favorites.add(channelId) - prefs.edit().putStringSet(KEY_FAVORITES, favorites).apply() - true - } - } - - fun isFavorite(context: Context, channelId: String): Boolean { - return getFavorites(context).contains(channelId) - } -} \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 25a6150..0000000 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4e54127..0000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_channels.xml b/android/app/src/main/res/layout/fragment_channels.xml deleted file mode 100644 index 8b11d9e..0000000 --- a/android/app/src/main/res/layout/fragment_channels.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_player.xml b/android/app/src/main/res/layout/fragment_player.xml deleted file mode 100644 index 68b1394..0000000 --- a/android/app/src/main/res/layout/fragment_player.xml +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -