diff --git a/.dockerignore b/.dockerignore index b99721b..1cca76e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,8 @@ prompt.md .ruff_cache/ .venv/ frontend/node_modules -frontend/dist \ No newline at end of file +frontend/dist +android/.gradle +android/app/build +android/.idea +android/*.iml \ No newline at end of file diff --git a/.env.example b/.env.example index cdced3c..2255b1a 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,8 @@ LOG_LEVEL=info VITE_API_URL=http://localhost:8000 VITE_APP_TITLE=Lofi Radio +# Android +ANDROID_SERVER_URL=http://localhost:8000 + # Docker COMPOSE_PROJECT_NAME=lofi-app \ No newline at end of file diff --git a/.gitignore b/.gitignore index 25778d8..6a5a188 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,12 @@ data/* Thumbs.db # Docker -*.log \ No newline at end of file +*.log + +# Android +android/.gradle/ +android/.idea/ +android/*.iml +android/app/build/ +android/app/debug +android/app/release \ No newline at end of file diff --git a/android/Dockerfile b/android/Dockerfile new file mode 100644 index 0000000..ce69d1b --- /dev/null +++ b/android/Dockerfile @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..6e041b6 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000..2cd6ebf --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +-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.** \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..70857ce --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 0000000..c2c51b6 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/LofiRadioApp.kt @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..7295524 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/data/Channels.kt @@ -0,0 +1,699 @@ +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 new file mode 100644 index 0000000..2b31ba7 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/data/models/Channel.kt @@ -0,0 +1,13 @@ +package com.lofiradio.data.models + +data class Channel( + val id: String, + val name: String, + val handle: String, + val description: String, + val thumbnail: String?, + var isLive: Boolean = false, + var videoId: String? = null, + var liveThumbnail: String? = null, + var 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 new file mode 100644 index 0000000..fee3abd --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/data/models/StreamInfo.kt @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..663d8fc --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/network/ServerApi.kt @@ -0,0 +1,83 @@ +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).asJsonObject + val channels = json.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 { + 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 new file mode 100644 index 0000000..c265e66 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt @@ -0,0 +1,282 @@ +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) + .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")) { + return url + } + } + return formats.firstOrNull { (url, mimeType) -> + mimeType.contains("audio") || mimeType.contains("mp4") + }?.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 new file mode 100644 index 0000000..f5c0956 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt @@ -0,0 +1,127 @@ +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") + } + Player.STATE_READY -> { + if (!exoPlayer.isPlaying) { + onError("Playback paused") + } + } + } + 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 new file mode 100644 index 0000000..20f5ef3 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt @@ -0,0 +1,178 @@ +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() + binding.swipeRefresh.isRefreshing = false + } + } + + private fun setupFab() { + binding.fabDiscover.setOnClickListener { + viewModel.discoverAllChannels() + } + } + + private fun observeViewModel() { + viewModel.channels.observe(viewLifecycleOwner) { + updateList() + } + + viewModel.isDiscovering.observe(viewLifecycleOwner) { + binding.fabDiscover.isEnabled = !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 new file mode 100644 index 0000000..b1207e7 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt @@ -0,0 +1,268 @@ +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 channel = _channelList.find { it.id == channelId } + if (channel != null) { + channel.isFavorite = Preferences.isFavorite(context, channelId) + 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 = 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) + + for (channel in _channelList.toList()) { + try { + when (mode) { + Preferences.StreamingMode.SERVER -> { + val live = serverApi.checkChannelLive(channel.id) + channel.isLive = live?.isLive ?: false + channel.videoId = live?.videoId + channel.liveThumbnail = live?.thumbnail + } + Preferences.StreamingMode.DIRECT -> { + val stream = YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey) + channel.isLive = stream != null + channel.videoId = stream?.videoId + } + } + } catch (e: Exception) { + Log.e("LofiViewModel", "Discovery error for ${channel.name}", e) + } + delay(500) + } + notifyChannelsChanged() + _isDiscovering.value = false + } + } + + fun getStreamingMode(): Preferences.StreamingMode { + return Preferences.getStreamingMode(context) + } + + override fun onCleared() { + audioPlayer.release() + } +} + diff --git a/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt b/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt new file mode 100644 index 0000000..828bdd4 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt @@ -0,0 +1,77 @@ +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() + .replace(R.id.fragment_container, ChannelListFragment()) + .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()) + true + } + R.id.navigation_player -> { + showFragment(PlayerFragment()) + true + } + R.id.navigation_settings -> { + showFragment(SettingsFragment()) + true + } + else -> false + } + } + } + + private fun showFragment(fragment: androidx.fragment.app.Fragment) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit() + } + + override fun onPause() { + super.onPause() + } + + override fun onResume() { + super.onResume() + } + + override fun onDestroy() { + super.onDestroy() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt b/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt new file mode 100644 index 0000000..6c0e616 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/ui/PlayerFragment.kt @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..9e56420 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/ui/SettingsFragment.kt @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..59d0d52 --- /dev/null +++ b/android/app/src/main/java/com/lofiradio/util/Preferences.kt @@ -0,0 +1,84 @@ +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, "http://192.168.1.100:8000") + ?: "http://192.168.1.100:8000" + } + + 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 new file mode 100644 index 0000000..25a6150 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + \ 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 new file mode 100644 index 0000000..4e54127 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,22 @@ + + + + + + + + \ 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 new file mode 100644 index 0000000..8b11d9e --- /dev/null +++ b/android/app/src/main/res/layout/fragment_channels.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 0000000..68b1394 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_player.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +