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