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:
parent
ae822ea505
commit
cdc19be933
@ -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
|
||||
@ -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")
|
||||
}
|
||||
13
android/app/proguard-rules.pro
vendored
13
android/app/proguard-rules.pro
vendored
@ -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 { *; }
|
||||
@ -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>
|
||||
@ -1,9 +0,0 @@
|
||||
package com.lofiradio
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class LofiRadioApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
@ -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?
|
||||
)
|
||||
@ -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}")))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@ -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
33
android/gradlew
vendored
@ -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 "$@"
|
||||
@ -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")
|
||||
Loading…
x
Reference in New Issue
Block a user