refactor: move Android client to lofi-android

This repo keeps the Python streaming server, web frontend, and
docker deployment. The Android client moves to its own repository.
This commit is contained in:
Jarian Cottingham 2026-08-21 18:35:50 +00:00
parent ae822ea505
commit cdc19be933
35 changed files with 0 additions and 3098 deletions

View File

@ -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

View File

@ -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")
}

View File

@ -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 { *; }

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application
android:name=".LofiRadioApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.LofiRadio"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.LofiRadio">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,9 +0,0 @@
package com.lofiradio
import android.app.Application
class LofiRadioApp : Application() {
override fun onCreate() {
super.onCreate()
}
}

View File

@ -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"
)
)

View File

@ -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
)

View File

@ -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<ServerChannel>
)
data class ServerChannel(
val id: String,
val name: String,
val handle: String,
val description: String,
val isLive: Boolean,
val videoId: String?,
val thumbnail: String?
)

View File

@ -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<com.lofiradio.data.models.ServerChannel>? {
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}")))
}
}
})
}
}
}

View File

@ -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<Pair<String, String>> {
val formats = mutableListOf<Pair<String, String>>()
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<Pair<String, String>>): 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<Pair<String, String>>): 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<String>()
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
}
}

View File

@ -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))
}
}
}
}

View File

@ -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<Channel, ChannelAdapter.ChannelViewHolder>(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<Channel>() {
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<com.google.android.material.bottomnavigation.BottomNavigationView>(R.id.bottom_navigation)
nav.selectedItemId = R.id.navigation_player
}
}
}
}

View File

@ -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<Channel>()
private val _channels = androidx.lifecycle.MutableLiveData<List<Channel>>(emptyList())
val channels: androidx.lifecycle.LiveData<List<Channel>> get() = _channels
private fun notifyChannelsChanged() {
_channels.value = _channelList.toList()
}
private val _currentChannel = androidx.lifecycle.MutableLiveData<Channel?>(null)
val currentChannel: androidx.lifecycle.LiveData<Channel?> get() = _currentChannel
private val _playerState = androidx.lifecycle.MutableLiveData<PlayerState>(PlayerState.IDLE)
val playerState: androidx.lifecycle.LiveData<PlayerState> get() = _playerState
private val _isLoading = androidx.lifecycle.MutableLiveData<Boolean>(false)
val isLoading: androidx.lifecycle.LiveData<Boolean> get() = _isLoading
private val _error = androidx.lifecycle.MutableLiveData<String?>(null)
val error: androidx.lifecycle.LiveData<String?> get() = _error
private val _isDiscovering = androidx.lifecycle.MutableLiveData<Boolean>(false)
val isDiscovering: androidx.lifecycle.LiveData<Boolean> 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<Channel> {
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<Channel>()
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() {
}
}

View File

@ -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 <T : androidx.lifecycle.ViewModel> create(modelClass: kotlin.Class<T>): 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()
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String> {
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<String>(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)
}
}

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/accent"
android:pathData="M36,30 L72,30 L72,42 L36,42 Z" />
<path
android:fillColor="@color/accent"
android:pathData="M36,54 L60,54 L60,66 L36,66 Z" />
<path
android:fillColor="@color/accent"
android:pathData="M36,78 L48,78 L48,90 L36,90 Z" />
</vector>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface">
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="56dp" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_gravity="bottom"
android:background="@color/primary_dark"
app:menu="@menu/bottom_navigation_menu" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
android:elevation="4dp"
app:title="@string/channels"
app:titleTextColor="@color/on_primary" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabTextColor="@color/on_surface_variant"
app:tabSelectedTextColor="@color/accent"
app:tabIndicatorColor="@color/accent" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/channel_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_discover"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/discover_streams"
android:src="@android:drawable/ic_menu_search"
app:backgroundTint="@color/accent" />
</FrameLayout>
</LinearLayout>

View File

@ -1,189 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/player_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp"
android:gravity="center_horizontal">
<com.google.android.material.appbar.MaterialToolbar
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:title="@string/now_playing"
app:titleTextColor="@color/on_primary"
android:layout_marginBottom="24dp" />
<androidx.cardview.widget.CardView
android:layout_width="180dp"
android:layout_height="180dp"
app:cardCornerRadius="16dp"
app:cardElevation="8dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/player_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@null"
tools:background="@color/accent" />
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/player_channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/on_surface"
android:textSize="22sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="4dp"
tools:text="Lofi Girl" />
<TextView
android:id="@+id/player_video_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_variant"
android:textSize="14sp"
android:gravity="center"
android:layout_marginBottom="8dp"
tools:text="beats to relax/study to" />
<TextView
android:id="@+id/player_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/accent"
android:textSize="11sp"
android:gravity="center"
android:layout_marginBottom="24dp"
tools:text="Direct Mode" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginBottom="24dp">
<ImageButton
android:id="@+id/btn_previous"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/previous"
android:src="@android:drawable/ic_media_previous"
app:tint="@color/on_surface" />
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/play"
android:src="@android:drawable/ic_btn_now_playing"
app:tint="@color/accent" />
<ImageButton
android:id="@+id/btn_next"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/next"
android:src="@android:drawable/ic_media_next"
app:tint="@color/on_surface" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<ImageButton
android:id="@+id/btn_volume"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/volume"
android:src="@android:drawable/ic_btn_volumelow"
app:tint="@color/on_surface_variant" />
<SeekBar
android:id="@+id/volume_slider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:progress="80"
android:thumbTint="@color/accent"
android:progressTint="@color/accent" />
<TextView
android:id="@+id/volume_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:layout_marginStart="8dp"
tools:text="80%" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/player_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:indeterminate="true"
app:indicatorColor="@color/accent"
app:trackColor="@color/card_background" />
<TextView
android:id="@+id/player_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:gravity="center"
tools:text="Playing" />
<LinearLayout
android:id="@+id/error_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/accent"
android:textSize="14sp"
android:gravity="center"
tools:text="Error playing stream" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Retry"
style="@style/Widget.LofiRadio.Button" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@ -1,238 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
android:elevation="4dp"
app:title="@string/settings"
app:titleTextColor="@color/on_primary" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.LofiRadio.Card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/streaming_mode"
android:textColor="@color/on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/streaming_mode_desc"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:layout_marginBottom="16dp" />
<RadioGroup
android:id="@+id/radio_streaming_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/mode_server"
android:textColor="@color/on_surface"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/mode_server_desc"
android:textColor="@color/on_surface_variant"
android:textSize="11sp"
android:layout_marginStart="36dp"
android:layout_marginBottom="12dp" />
<RadioButton
android:id="@+id/radio_direct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/mode_direct"
android:textColor="@color/on_surface"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/mode_direct_desc"
android:textColor="@color/on_surface_variant"
android:textSize="11sp"
android:layout_marginStart="36dp" />
</RadioGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.LofiRadio.Card"
android:id="@+id/server_settings_card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_url"
android:textColor="@color/on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_url_desc"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_server_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.1.100:8000"
android:inputType="textUri"
android:textColor="@color/on_surface" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.LofiRadio.Card"
android:id="@+id/youtube_api_card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/youtube_api_key"
android:textColor="@color/on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/youtube_api_key_desc"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_youtube_api_key"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="AIza..."
android:inputType="text"
android:textColor="@color/on_surface" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.LofiRadio.Card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/auto_advance"
android:textColor="@color/on_surface"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/auto_advance_desc"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:layout_marginBottom="8dp" />
<Switch
android:id="@+id/switch_auto_advance"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lofi Radio v1.0"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:gravity="center"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
style="@style/Widget.LofiRadio.Card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/channel_thumbnail"
android:layout_width="56dp"
android:layout_height="56dp"
android:scaleType="centerCrop"
android:contentDescription="@null"
tools:background="@color/accent" />
<Space
android:layout_width="12dp"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/on_surface"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Lofi Girl" />
<TextView
android:id="@+id/channel_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_variant"
android:textSize="12sp"
android:maxLines="2"
tools:text="The most popular lofi hip hop radio" />
<TextView
android:id="@+id/channel_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/live_indicator"
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="● LIVE"
tools:visibility="visible" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/btn_favorite"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_favorite"
android:src="@android:drawable/btn_star_big_off"
app:tint="@color/on_surface_variant" />
<ImageButton
android:id="@+id/btn_play"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/play"
android:src="@android:drawable/ic_btn_now_playing"
app:tint="@color/accent" />
</FrameLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_channels"
android:icon="@android:drawable/ic_menu_gallery"
android:title="@string/channels" />
<item
android:id="@+id/navigation_player"
android:icon="@android:drawable/ic_btn_multimedia"
android:title="@string/now_playing" />
<item
android:id="@+id/navigation_settings"
android:icon="@android:drawable/ic_menu_preferences"
android:title="@string/settings" />
</menu>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="primary">#FF1A1A2E</color>
<color name="primary_dark">#FF0F0F1A</color>
<color name="accent">#FFE94560</color>
<color name="surface">#FF16213E</color>
<color name="surface_variant">#FF1A1A2E</color>
<color name="on_primary">#FFFFFFFF</color>
<color name="on_surface">#FFE0E0E0</color>
<color name="on_surface_variant">#FFB0B0B0</color>
<color name="card_background">#FF222240</color>
<color name="player_background">#FF0F0F1A</color>
<color name="live_indicator">#FF00C853</color>
<color name="disabled">#FF4A4A6A</color>
</resources>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lofi Radio</string>
<string name="channels">Channels</string>
<string name="now_playing">Now Playing</string>
<string name="settings">Settings</string>
<string name="no_channels">No channels found</string>
<string name="loading">Loading…</string>
<string name="not_live">Not live</string>
<string name="live">Live</string>
<string name="play">Play</string>
<string name="pause">Pause</string>
<string name="stop">Stop</string>
<string name="next">Next</string>
<string name="previous">Previous</string>
<string name="volume">Volume</string>
<string name="error_playing">Error playing stream</string>
<string name="error_network">Network error</string>
<string name="error_not_live">Channel is not currently live</string>
<string name="error_extract_stream">Could not extract stream</string>
<string name="discover_streams">Discover Streams</string>
<string name="auto_advance">Auto-advance to next channel</string>
<string name="auto_advance_desc">Automatically switch to the next live channel when current stream ends</string>
<string name="streaming_mode">Streaming Mode</string>
<string name="streaming_mode_desc">Choose how to stream YouTube audio</string>
<string name="mode_server">Server Proxy</string>
<string name="mode_server_desc">Stream through your backend server</string>
<string name="mode_direct">Direct from YouTube</string>
<string name="mode_direct_desc">Extract streams directly on your device (no server needed)</string>
<string name="server_url">Server URL</string>
<string name="server_url_desc">Your backend server address</string>
<string name="youtube_api_key">YouTube API Key</string>
<string name="youtube_api_key_desc">Required for direct mode channel discovery</string>
<string name="favorites">Favorites</string>
<string name="add_favorite">Add to favorites</string>
<string name="remove_favorite">Remove from favorites</string>
<string name="all_channels">All Channels</string>
</resources>

View File

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LofiRadio" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:colorBackground">@color/surface</item>
<item name="android:windowBackground">@color/surface</item>
<item name="android:textColor">@color/on_surface</item>
<item name="android:textColorSecondary">@color/on_surface_variant</item>
<item name="bottomNavigationStyle">@style/Widget.LofiRadio.BottomNavigationView</item>
</style>
<style name="Widget.LofiRadio.BottomNavigationView" parent="Widget.Material3.BottomNavigationView">
<item name="android:background">@color/primary_dark</item>
<item name="itemActiveIconTint">@color/accent</item>
<item name="itemInactiveIconTint">@color/on_surface_variant</item>
<item name="itemActiveTintColor">@color/accent</item>
<item name="itemInactiveTintColor">@color/on_surface_variant</item>
</style>
<style name="Widget.LofiRadio.Card" parent="Widget.Material3.CardView.Elevated">
<item name="cardBackgroundColor">@color/card_background</item>
<item name="cardCornerRadius">12dp</item>
<item name="cardElevation">4dp</item>
<item name="cardUseCompatPadding">true</item>
</style>
<style name="Widget.LofiRadio.Button" parent="Widget.Material3.Button">
<item name="backgroundTint">@color/accent</item>
<item name="cornerRadius">8dp</item>
</style>
<style name="Widget.LofiRadio.Button.Outlined" parent="Widget.Material3.Button.OutlinedButton">
<item name="strokeColor">@color/accent</item>
<item name="android:textColor">@color/accent</item>
</style>
</resources>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.0.0/8</domain>
<domain includeSubdomains="true">192.168.0.0/16</domain>
</domain-config>
</network-security-config>

View File

@ -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
}

View File

@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -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

33
android/gradlew vendored
View File

@ -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 "$@"

View File

@ -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")