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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/fragment_settings.xml b/android/app/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 0000000..e2bba63
--- /dev/null
+++ b/android/app/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/item_channel.xml b/android/app/src/main/res/layout/item_channel.xml
new file mode 100644
index 0000000..5645bac
--- /dev/null
+++ b/android/app/src/main/res/layout/item_channel.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/menu/bottom_navigation_menu.xml b/android/app/src/main/res/menu/bottom_navigation_menu.xml
new file mode 100644
index 0000000..cf5fcc1
--- /dev/null
+++ b/android/app/src/main/res/menu/bottom_navigation_menu.xml
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..e1199a7
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..a61c695
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+
+ #FF000000
+ #FFFFFFFF
+ #FF1A1A2E
+ #FF0F0F1A
+ #FFE94560
+ #FF16213E
+ #FF1A1A2E
+ #FFFFFFFF
+ #FFE0E0E0
+ #FFB0B0B0
+ #FF222240
+ #FF0F0F1A
+ #FF00C853
+ #FF4A4A6A
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..e1bb3f2
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,39 @@
+
+
+ Lofi Radio
+
+ Channels
+ Now Playing
+ Settings
+ No channels found
+ Loading…
+ Not live
+ Live
+ Play
+ Pause
+ Stop
+ Next
+ Previous
+ Volume
+ Error playing stream
+ Network error
+ Channel is not currently live
+ Could not extract stream
+ Discover Streams
+ Auto-advance to next channel
+ Automatically switch to the next live channel when current stream ends
+ Streaming Mode
+ Choose how to stream YouTube audio
+ Server Proxy
+ Stream through your backend server
+ Direct from YouTube
+ Extract streams directly on your device (no server needed)
+ Server URL
+ Your backend server address
+ YouTube API Key
+ Required for direct mode channel discovery
+ Favorites
+ Add to favorites
+ Remove from favorites
+ All Channels
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..319df3c
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..f7a03de
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+ localhost
+ 10.0.0.0/8
+ 192.168.0.0/16
+
+
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..289105c
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "8.7.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..0dee8cb
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..fa6249b
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..58186b6
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+##############################################################################
+##
+## Gradle start up script for POSIX generated by Gradle.
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links; $0 may be a link
+app_path=$0
+# Need this for correct symlinking resolution
+while
+ app_path_is_symlink=1
+ case $app_path in
+ /*) app_path_resolve="$app_path" ;;
+ *) app_path_resolve="./$app_path" ;;
+ esac
+ app_path_resolve=$(cd "$(dirname "$app_path_resolve")" && pwd)/$(basename "$app_path_resolve")
+ app_path=$(readlink -f "$app_path_resolve") || app_path_is_symlink=0
+ [ $app_path_is_symlink != 0 ]
+do
+ :
+done
+APP_HOME=$(cd -P "$(dirname "$app_path_resolve")" && pwd)
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Add default JVM options here
+JAVA_OPTS="${JAVA_OPTS:-}"
+
+exec "$JAVACMD" $JAVA_OPTS -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
\ No newline at end of file
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..c92ce20
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "LofiRadio"
+include(":app")
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index be03875..e1d9c47 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -30,6 +30,23 @@ services:
networks:
- lofi-net
+ android:
+ build:
+ context: ../android
+ dockerfile: Dockerfile
+ container_name: lofi-android-build
+ volumes:
+ - ../android:/app
+ - android-gradle-cache:/root/.gradle
+ environment:
+ - YOUTUBE_API_KEY=${YOUTUBE_API_KEY:-}
+ networks:
+ - lofi-net
+
+volumes:
+ android-gradle-cache:
+ driver: local
+
networks:
lofi-net:
driver: bridge