From a5ace4d6d4beaf2007e0781ed6fb6e63d1c736f1 Mon Sep 17 00:00:00 2001 From: Erfan Date: Mon, 2 Feb 2026 13:18:05 +0330 Subject: [PATCH 1/7] chore(ci): separate dev and prod Android APK builds --- .github/workflows/react-native-cicd.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 89110de..6b75595 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -127,8 +127,10 @@ jobs: # PRODUCTION build (standalone, offline, no Metro required) # ---------------------------------------------------------- - - name: 📱 Build Production APK - if: github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'prod-apk' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) + - name: 📱 Build Production APK (main/master) + if: > + (github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'prod-apk' || github.event_name == 'push') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') run: | export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" @@ -151,6 +153,17 @@ jobs: env: NODE_ENV: production + - name: 📱 Build Debug APK (development branch) + if: github.event_name == 'push' && github.ref == 'refs/heads/development' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + + echo "Building debug APK for development branch using EAS cloud build..." + + eas build --platform android --profile debug-apk --non-interactive --wait + + echo "Development debug APK build completed." + - name: 🔍 Print Gradle log (if failed) if: failure() run: | From 24944679b362ea72be5cebe7d942ab40db6f03d5 Mon Sep 17 00:00:00 2001 From: Erfan Date: Mon, 2 Feb 2026 13:28:53 +0330 Subject: [PATCH 2/7] ci: separate development and main branch workflows - Created dedicated development-build.yml workflow for development branch - Updated react-native-cicd.yml to only handle main/master branches - Development branch now builds debug-apk instead of production-apk - Each workflow is optimized for its respective branch type --- .github/workflows/development-build.yml | 121 ++++++++++++++++++++++++ .github/workflows/react-native-cicd.yml | 15 +-- 2 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/development-build.yml diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml new file mode 100644 index 0000000..4e3c7fe --- /dev/null +++ b/.github/workflows/development-build.yml @@ -0,0 +1,121 @@ +name: Development Debug APK + +on: + push: + branches: [development] + pull_request: + branches: [development] + workflow_dispatch: + +env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + NODE_OPTIONS: --openssl-legacy-provider + +jobs: + build-dev-apk: + runs-on: ubuntu-latest + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: 📦 Install dependencies + run: | + npm ci --include=dev + npm install -g eas-cli@latest + + - name: 📱 Setup EAS build cache + uses: actions/cache@v3 + with: + path: ~/.eas-build-local + key: ${{ runner.os }}-eas-build-local-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-eas-build-local- + + - name: 🔄 Verify EAS CLI installation + run: | + echo "EAS CLI version:" + eas --version + + - name: 📋 Fix package.json main entry + run: | + if ! command -v jq &> /dev/null; then + sudo apt-get update && sudo apt-get install -y jq + fi + cp package.json package.json.bak + jq '.main = "node_modules/expo/AppEntry.js"' package.json > package.json.tmp && mv package.json.tmp package.json + cat package.json | grep "main" + + - name: 📋 Update metro.config.js for SVG support + run: | + if [ -f ./metro.config.js ]; then + cp ./metro.config.js ./metro.config.js.backup + cat > ./metro.config.js << 'EOFMARKER' + /* eslint-disable @typescript-eslint/no-var-requires */ + const { getDefaultConfig } = require('expo/metro-config'); + const config = getDefaultConfig(__dirname); + const { transformer, resolver } = config; + config.transformer = { + ...transformer, + babelTransformerPath: require.resolve('react-native-svg-transformer/expo'), + }; + config.resolver = { + ...resolver, + assetExts: resolver.assetExts.filter(ext => ext !== 'svg'), + sourceExts: [...resolver.sourceExts, 'svg'], + }; + module.exports = config; + EOFMARKER + fi + + # ---------------------------------------------------------- + # PREBUILD - Generate native Android files + # ---------------------------------------------------------- + - name: 🏗 Run Prebuild (Generate Android files) + run: | + echo "Running prebuild to generate Android directory..." + npx expo prebuild --platform android --clean + + # ---------------------------------------------------------- + # DEVELOPMENT build - Debug APK only + # ---------------------------------------------------------- + - name: 📱 Build Debug APK (development profile) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + + echo "Building debug APK for development branch using EAS cloud build..." + + # Build and wait for completion + eas build --platform android --profile debug-apk --non-interactive --wait + + echo "Development debug APK build completed! 🎉" + echo "" + echo "The development APK has been built successfully!" + echo "To download your debug APK:" + echo "1. Visit your EAS dashboard at: https://expo.dev/accounts/[your-account]/projects/streamify/builds" + echo "2. Look for the latest completed debug build" + echo "3. Click on it to download the APK file" + echo "" + echo "Alternatively, you can run: eas build:list --platform android --limit 5" + echo "to see your recent builds and their download links." + env: + NODE_ENV: development + + - name: 🔍 Print Gradle log (if failed) + if: failure() + run: | + LOG=$(ls -t ./logs/*.log 2>/dev/null | head -n1) + if [ -f "$LOG" ]; then cat "$LOG"; else echo "No gradle log found"; fi + + - name: 📦 Upload debug APK + if: always() + uses: actions/upload-artifact@v4 + with: + name: app-dev-apk + path: ./**/*.apk + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 7bb9256..a2ab261 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -2,13 +2,13 @@ name: React Native CI/CD on: push: - branches: [main, master, development] + branches: [main, master] paths-ignore: - "**.md" - "LICENSE" - "docs/**" pull_request: - branches: [main, master, development] + branches: [main, master] workflow_dispatch: inputs: buildType: @@ -32,7 +32,7 @@ jobs: build-and-deploy: needs: check-skip - if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/development')) || github.event_name == 'workflow_dispatch' + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: 🏗 Checkout repository @@ -136,16 +136,7 @@ jobs: env: NODE_ENV: production - - name: 📱 Build Debug APK (development branch) - if: github.event_name == 'push' && github.ref == 'refs/heads/development' - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - - echo "Building debug APK for development branch using EAS cloud build..." - - eas build --platform android --profile debug-apk --non-interactive --wait - echo "Development debug APK build completed." - name: 🔍 Print Gradle log (if failed) if: failure() From a60d91fdf32cd5e8eca808b8b0f932a1385a1afe Mon Sep 17 00:00:00 2001 From: Erfan Date: Mon, 2 Feb 2026 16:32:44 +0330 Subject: [PATCH 3/7] chore: disable new architecture and extend React Native types Add `newArchEnabled: false` to Expo config to opt out of the new React Native architecture. Extend custom `react-native.d.ts` type declaration to include the `Switch` component. --- app.json | 1 + react-native.d.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/app.json b/app.json index 1078327..ddfc737 100644 --- a/app.json +++ b/app.json @@ -1,5 +1,6 @@ { "expo": { + "newArchEnabled": false, "name": "Streamify", "slug": "streamify-mobile", "scheme": "streamify", diff --git a/react-native.d.ts b/react-native.d.ts index 272f1b3..a5f39b5 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -9,6 +9,7 @@ declare module "react-native" { export class TouchableOpacity extends React.Component {} export class TouchableWithoutFeedback extends React.Component {} export class Image extends React.Component {} + export class Switch extends React.Component {} export class FlatList extends React.Component {} export class ActivityIndicator extends React.Component {} export class Keyboard { From a9eaf70347fd2d48a3020aa97d1e808916db68e2 Mon Sep 17 00:00:00 2001 From: Erfan Date: Mon, 2 Feb 2026 17:03:53 +0330 Subject: [PATCH 4/7] chore: remove react-native-reanimated and LoadingScreen component The react-native-reanimated library is no longer needed as the LoadingScreen component that depended on it has been removed. This simplifies the project dependencies and reduces bundle size. - Remove react-native-reanimated from package.json and package-lock.json - Remove the LoadingScreen component file - Remove react-native-reanimated plugin from babel.config.js - Update react-native-draggable-flatlist peerDependencies to remove reanimated requirement --- babel.config.js | 2 +- components/LoadingScreen.tsx | 72 ------------------------------------ package-lock.json | 34 ++--------------- package.json | 1 - 4 files changed, 4 insertions(+), 105 deletions(-) delete mode 100644 components/LoadingScreen.tsx diff --git a/babel.config.js b/babel.config.js index 94114e9..90ffd0d 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,7 +3,7 @@ module.exports = function (api) { return { presets: ["babel-preset-expo"], plugins: [ - "react-native-reanimated/plugin", + [ "module:react-native-dotenv", { diff --git a/components/LoadingScreen.tsx b/components/LoadingScreen.tsx deleted file mode 100644 index 2997ed0..0000000 --- a/components/LoadingScreen.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useEffect } from "react"; -import { View, Image, StyleSheet } from "react-native"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from "react-native-reanimated"; - -interface LoadingScreenProps { - onLoadingComplete: () => void; -} - -export const LoadingScreen: React.FC = ({ - onLoadingComplete, -}) => { - const fadeAnim = useSharedValue(1); - - useEffect(() => { - // Simulate loading time (you can adjust this or make it dynamic based on actual loading) - const timer = setTimeout(() => { - // Start fade out animation - fadeAnim.value = withTiming(0, { duration: 800 }, () => { - // Call the completion callback when fade is done - onLoadingComplete(); - }); - }, 1500); // Show loading screen for 1.5 seconds - - return () => clearTimeout(timer); - }, [fadeAnim, onLoadingComplete]); - - const animatedStyle = useAnimatedStyle(() => { - return { - opacity: fadeAnim.value, - }; - }); - - return ( - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "#000000", - zIndex: 9999, - elevation: 9999, - }, - imageContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - loadingImage: { - width: "80%", - height: "80%", - maxWidth: 300, - maxHeight: 300, - }, -}); diff --git a/package-lock.json b/package-lock.json index 3f825ab..c327c66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,6 @@ "react-native-dotenv": "^3.4.11", "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.28.0", - "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", @@ -13179,8 +13178,7 @@ }, "peerDependencies": { "react-native": ">=0.64.0", - "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=2.8.0" + "react-native-gesture-handler": ">=2.0.0" } }, "node_modules/react-native-gesture-handler": { @@ -13208,34 +13206,8 @@ "react-native": "*" } }, - "node_modules/react-native-reanimated": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", - "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", - "license": "MIT", - "dependencies": { - "react-native-is-edge-to-edge": "^1.2.1", - "semver": "7.7.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0", - "react": "*", - "react-native": "*", - "react-native-worklets": ">=0.5.0" - } - }, - "node_modules/react-native-reanimated/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, + + "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", diff --git a/package.json b/package.json index 232a47f..e2dfcbb 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "react-native-dotenv": "^3.4.11", "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.28.0", - "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", From 1a8da0a4bc62fa75633bef41f638c6ef5e4c191a Mon Sep 17 00:00:00 2001 From: Erfan Date: Fri, 6 Feb 2026 12:23:49 +0330 Subject: [PATCH 5/7] feat: add YouTube Music support and improve API reliability - Add YouTube Music as a search source with dedicated filters - Implement safe TrackPlayer wrapper to prevent crashes when native module is missing - Centralize API configuration and add instance fallback mechanisms - Add dynamic Invidious instance fetching from Uma repository on app startup - Enhance playlist support for YouTube/YouTube Music sources - Improve search result formatting and StreamItem component display - Add retry logic for API calls and better error handling - Fix color theme update timing in PlayerContext --- App.tsx | 19 +- babel.config.js | 1 - components/FullPlayerModal.tsx | 2 +- components/StreamItem.tsx | 257 ++++++- components/core/api.ts | 324 +++++++- components/screens/AlbumPlaylistScreen.tsx | 131 +++- components/screens/ArtistScreen.tsx | 341 ++++----- components/screens/HomeScreen.tsx | 197 +++-- components/screens/SearchScreen.tsx | 287 +++++--- components/screens/SettingsScreen.tsx | 7 +- contexts/PlayerContext.tsx | 8 +- locales/fa.json | 523 +++++++++++++ modules/audioStreaming.ts | 811 +++++++++++---------- modules/searchAPI.ts | 737 +++++++++++++++---- modules/store.ts | 17 +- services/TrackPlayerService.ts | 363 +++++++-- services/playbackService.ts | 2 +- utils/localization.ts | 30 +- utils/safeTrackPlayer.ts | 105 +++ 19 files changed, 3165 insertions(+), 997 deletions(-) create mode 100644 locales/fa.json create mode 100644 utils/safeTrackPlayer.ts diff --git a/App.tsx b/App.tsx index 40c98cb..e53d9dc 100644 --- a/App.tsx +++ b/App.tsx @@ -4,7 +4,7 @@ import { NavigationContainer } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { enableScreens } from "react-native-screens"; -import TrackPlayer from "react-native-track-player"; +import TrackPlayer from "./utils/safeTrackPlayer"; import { View, TouchableOpacity, @@ -23,6 +23,9 @@ import { MaterialIcons } from "@expo/vector-icons"; // Context import { PlayerProvider } from "./contexts/PlayerContext"; +// API +import { updateInvidiousInstancesFromUma } from "./components/core/api"; + // Components import { MiniPlayer } from "./components/MiniPlayer"; import { FullPlayerModal } from "./components/FullPlayerModal"; @@ -43,7 +46,7 @@ enableScreens(); // Register the playback service for proper media session integration try { TrackPlayer.registerPlaybackService(() => - require("./services/playbackService"), + require("./services/playbackService") ); console.log("[App] Playback service registered successfully"); } catch (error) { @@ -377,6 +380,18 @@ export default function App() { GoogleSansSemiBold: require("./assets/fonts/GoogleSansSemiBold.ttf"), GoogleSansBold: require("./assets/fonts/GoogleSansBold.ttf"), }); + + // Fetch Invidious instances on app startup + useEffect(() => { + const fetchInstances = async () => { + console.log("[App] Fetching Invidious instances from Uma repository..."); + await updateInvidiousInstancesFromUma(); + console.log("[App] Invidious instances update complete"); + }; + + fetchInstances(); + }, []); + // const [showLoadingScreen, setShowLoadingScreen] = useState(true); // const [isLoadingComplete, setIsLoadingComplete] = useState(false); diff --git a/babel.config.js b/babel.config.js index 90ffd0d..e3a78c4 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,7 +3,6 @@ module.exports = function (api) { return { presets: ["babel-preset-expo"], plugins: [ - [ "module:react-native-dotenv", { diff --git a/components/FullPlayerModal.tsx b/components/FullPlayerModal.tsx index e8f1b97..cda0949 100644 --- a/components/FullPlayerModal.tsx +++ b/components/FullPlayerModal.tsx @@ -18,7 +18,7 @@ import styled from "styled-components/native"; import { FontAwesome6, Ionicons } from "@expo/vector-icons"; import { Entypo } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; -import TrackPlayer from "react-native-track-player"; +import TrackPlayer from "../utils/safeTrackPlayer"; import { LinearGradient } from "expo-linear-gradient"; import { usePlayer } from "../contexts/PlayerContext"; import { formatTime } from "../utils/formatters"; diff --git a/components/StreamItem.tsx b/components/StreamItem.tsx index 4b34530..89064c6 100644 --- a/components/StreamItem.tsx +++ b/components/StreamItem.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from "react"; import { View, Text } from "react-native"; import styled from "styled-components/native"; +import { MaterialIcons } from "@expo/vector-icons"; export type StreamItemProps = { id: string; @@ -11,6 +12,7 @@ export type StreamItemProps = { uploaded?: string; channelUrl?: string; views?: string; + videoCount?: string; // For playlists - number of videos img?: string; draggable?: boolean; lastUpdated?: string; @@ -18,6 +20,12 @@ export type StreamItemProps = { isAlbum?: boolean; albumYear?: string; source?: string; // Add source to identify JioSaavn results + type?: string; // Add type to identify artist items + channelDescription?: string; // Add channel description for artists/channels + verified?: boolean; // Add verified badge for channels + showGrayLayers?: boolean; // Add prop to control gray layer visibility + searchFilter?: string; // Add search filter to control metadata display + searchSource?: string; // Add search source to control metadata display }; const Row = styled.View` @@ -27,21 +35,51 @@ const Row = styled.View` gap: 12px; `; -const ThumbWrap = styled.View<{ source?: string }>` +const ThumbWrap = styled.View<{ + source?: string; + isPlaylist?: boolean; + type?: string; +}>` width: 96px; /* w-24 */ - height: ${(props) => - props.source === "jiosaavn" - ? "96px" - : "56px"}; /* 1:1 for JioSaavn, 16:9 for others */ - background-color: #262626; /* neutral-800 */ - border-radius: 8px; + height: ${(props) => { + // JioSaavn albums and songs: square + if (props.source === "jiosaavn") { + return "96px"; + } + // Channels and artists: square container for circular cropping + if (props.source === "youtube_channel" || props.type === "artist") { + return "96px"; /* Square for circular cropping */ + } + return "54px"; /* Rectangular for videos/songs/albums/playlists */ + }}; + border-radius: ${(props) => { + // JioSaavn albums and songs: squared (no circular cropping) + if (props.source === "jiosaavn") { + return "8px"; /* Squared with rounded corners for all JioSaavn items */ + } + // Only make circular for YouTube channels and artist items + if (props.source === "youtube_channel" || props.type === "artist") { + return "48px"; /* 50% of width/height for circular shape */ + } + return "8px"; /* Default rounded corners for other content */ + }}; overflow: hidden; position: relative; `; -const Thumbnail = styled.Image` +const Thumbnail = styled.Image<{ + imgSource?: string; + imgType?: string; +}>` width: 100%; height: 100%; + border-radius: ${(props) => { + // Make circular for YouTube channels and artist items + if (props.imgSource === "youtube_channel" || props.imgType === "artist") { + return "48px"; /* 50% of width/height for circular shape */ + } + return "0px"; /* No border radius for other content */ + }}; `; const DurationBadge = styled.View` @@ -53,6 +91,15 @@ const DurationBadge = styled.View` background-color: rgba(0, 0, 0, 0.7); `; +const PlaylistBadge = styled.View` + position: absolute; + left: 4px; + top: 4px; + padding: 2px 6px; + border-radius: 4px; + background-color: rgba(59, 130, 246, 0.9); /* blue-500 with transparency */ +`; + const DurationText = styled.Text` color: #fff; font-size: 12px; @@ -60,8 +107,23 @@ const DurationText = styled.Text` line-height: 16px; `; +const PlaylistText = styled.Text` + color: #fff; + font-size: 11px; + font-family: GoogleSansRegular; + line-height: 16px; +`; + +const DetailsBadge = styled.Text` + color: #737373; /* neutral-500 - match details row color */ + font-size: 12px; + font-family: GoogleSansRegular; + margin-left: 8px; +`; + const Content = styled.View` flex: 1; + justify-content: center; `; const Title = styled.Text` @@ -71,10 +133,11 @@ const Title = styled.Text` line-height: 18px; `; -const MetaRow = styled.View` +const MetaRow = styled.View<{ isChannel?: boolean }>` flex-direction: row; - align-items: center; - gap: 8px; + align-items: flex-start; + flex-wrap: wrap; + gap: ${(props) => (props.isChannel ? "0px" : "8px")}; `; const Author = styled.Text` @@ -91,9 +154,50 @@ const SubMeta = styled.Text` line-height: 16px; `; +const VerifiedBadge = styled.View` + margin-left: 4px; +`; + +const PlaylistLayer1 = styled.View` + position: absolute; + top: 2px; + left: 2px; + width: 100%; + height: 100%; + background-color: #1f2937; /* gray-800 */ + border-radius: 6px; + z-index: -1; +`; + +const PlaylistLayer2 = styled.View` + position: absolute; + top: 4px; + left: 4px; + width: 100%; + height: 100%; + background-color: #111827; /* gray-900 */ + border-radius: 4px; + z-index: -2; +`; + function StreamItem(props: StreamItemProps) { - const { title, author, duration, views, uploaded, thumbnailUrl, source } = - props; + const { + title, + author, + duration, + views, + videoCount, + uploaded, + thumbnailUrl, + source, + type, + isAlbum, + channelDescription, + verified, + showGrayLayers = false, + searchFilter, + searchSource, + } = props; const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); @@ -106,27 +210,83 @@ function StreamItem(props: StreamItemProps) { setImageLoaded(true); }, []); - const formatAuthor = useCallback((authorName?: string) => { - return authorName ? authorName.replace(" - Topic", "") : ""; - }, []); + const formatAuthor = useCallback( + ( + authorName?: string, + source?: string, + type?: string, + channelDescription?: string + ) => { + if ( + !authorName || + authorName === "Unknown Artist" || + authorName === "Unknown" + ) { + // For artists and channels, show channel description if available + if ( + source === "jiosaavn" || + source === "youtube_channel" || + type === "artist" + ) { + if (channelDescription) { + // Return full channel description - let the UI handle truncation + return channelDescription; + } + return ""; + } + return "Unknown Artist"; + } + return authorName.replace(" - Topic", ""); + }, + [] + ); - const formatSubMeta = useCallback((views?: string, uploaded?: string) => { - const parts = []; - if (views) { - parts.push(views); - } - if (uploaded) { - const cleanedUploaded = uploaded.replace("Streamed ", ""); - parts.push(cleanedUploaded); - } - return parts.join(" • "); - }, []); + const formatSubMeta = useCallback( + ( + views?: string, + uploaded?: string, + source?: string, + type?: string, + videoCount?: string, + isAlbum?: boolean, + searchFilter?: string, + searchSource?: string + ) => { + const parts = []; + + // For YouTube Music songs filter, don't show view count or date + if (searchSource === "youtubemusic" && searchFilter === "songs") { + return ""; + } + + // Skip video count for albums/playlists - shown separately with blue badge + if (!isAlbum && type !== "playlist" && views) { + parts.push(views); + } + // Skip showing date for artists, channels, albums, and playlists + if ( + uploaded && + source !== "jiosaavn" && + source !== "youtube_channel" && + type !== "artist" && + !isAlbum && + type !== "playlist" + ) { + const cleanedUploaded = uploaded.replace("Streamed ", ""); + parts.push(cleanedUploaded); + } + return parts.join(" • "); + }, + [] + ); return ( - + {!!thumbnailUrl && !imageError ? ( )} - {/* Only show duration badge for meaningful durations and not for JioSaavn results */} + {/* Blue badge removed from thumbnails - now shown in details row */} + {/* Only show duration badge for meaningful durations and not for JioSaavn results or playlists */} {duration && duration !== "0" && duration !== "0:00" && - source !== "jiosaavn" && ( + source !== "jiosaavn" && + !videoCount && ( {duration} )} - {title} - - {!!author && {formatAuthor(author)}} - {formatSubMeta(views, uploaded)} + + {title} + {verified && ( + + + + )} + + + {!!author && ( + + {formatAuthor(author, source, type, channelDescription)} + + )} + + {formatSubMeta( + views, + uploaded, + source, + type, + videoCount, + isAlbum, + searchFilter, + searchSource + )} + {/* Show blue badge for albums/playlists with video count */} + {(isAlbum || type === "playlist") && videoCount && ( + {videoCount} videos + )} + @@ -179,6 +367,7 @@ export default React.memo(StreamItem, (prevProps, nextProps) => { prevProps.author === nextProps.author && prevProps.duration === nextProps.duration && prevProps.views === nextProps.views && + prevProps.videoCount === nextProps.videoCount && prevProps.uploaded === nextProps.uploaded && prevProps.thumbnailUrl === nextProps.thumbnailUrl && prevProps.isAlbum === nextProps.isAlbum && diff --git a/components/core/api.ts b/components/core/api.ts index 9998d84..be6c4af 100644 --- a/components/core/api.ts +++ b/components/core/api.ts @@ -1,12 +1,245 @@ +// Centralized API Configuration export const API = { + // Piped instances for YouTube content piped: ["https://api.piped.private.coffee"], - invidious: ["https://yewtu.be"], - proxy: [], + + // Invidious instances for YouTube content (will be updated dynamically) + invidious: [ + "https://inv-veltrix.zeabur.app/api/v1", + "https://inv-veltrix-2.zeabur.app/api/v1", + "https://inv-veltrix-3.zeabur.app/api/v1", + "https://yt.omada.cafe/api/v1", + "https://invidious.f5.si/api/v1", + ], + + // HLS streaming endpoints hls: ["https://api.piped.private.coffee"], + + // Hyperpipe API for additional functionality hyperpipe: ["https://hyperpipeapi.onrender.com"], - backend: "https://streamifyend.netlify.app", + + // JioSaavn API endpoints + jiosaavn: { + base: "https://streamifyjiosaavn.vercel.app/api", + search: "/search", + songs: "/songs", + albums: "/albums", + artists: "/artists", + playlists: "/search/playlists", + }, } as const; +// Type definitions for better compatibility +export type APIConfig = typeof API; +export type InvidiousInstance = (typeof API.invidious)[number]; +export type PipedInstance = (typeof API.piped)[number]; + +// Dynamic Invidious instances array (mutable) +export let DYNAMIC_INVIDIOUS_INSTANCES = [...API.invidious]; + +// Update function for dynamic Invidious instances +export function updateInvidiousInstances( + newInstances: readonly InvidiousInstance[] +) { + const uniqueInstances = [ + ...new Set([...DYNAMIC_INVIDIOUS_INSTANCES, ...newInstances]), + ]; + DYNAMIC_INVIDIOUS_INSTANCES = uniqueInstances; + return uniqueInstances; +} + +// Helper functions for instance management +export const idFromURL = (link: string | null) => + link?.match( + /(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)([_0-9a-z-]+)/i + )?.[7]; + +export const fetchJson = async ( + url: string, + signal?: AbortSignal +): Promise => + fetch(url, { signal }).then((res) => { + if (!res.ok) + throw new Error(`Network response was not ok: ${res.statusText}`); + return res.json() as Promise; + }); + +/** + * Fetches and decodes Invidious instances from Uma repository + */ +export async function fetchUma(): Promise { + try { + console.log("[API] Fetching Invidious instances from Uma repository..."); + const response = await fetch( + "https://raw.githubusercontent.com/n-ce/Uma/main/iv.txt" + ); + const text = await response.text(); + + let decompressedString = text; + const decodePairs: Record = { + $: "invidious", + "&": "inv", + "#": "iv", + "~": "com", + }; + + for (const code in decodePairs) { + const safeCode = code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(safeCode, "g"); + decompressedString = decompressedString.replace(regex, decodePairs[code]); + } + + const instances = decompressedString.split(",").map((i) => `https://${i}`); + console.log( + `[API] Successfully fetched ${instances.length} instances from Uma` + ); + return instances; + } catch (error) { + console.error("[API] Failed to fetch instances from Uma:", error); + return []; + } +} + +/** + * Updates Invidious instances from Uma repository (for app startup) + */ +export async function updateInvidiousInstancesFromUma(): Promise { + try { + const umaInstances = await fetchUma(); + if (umaInstances.length > 0) { + updateInvidiousInstances(umaInstances as InvidiousInstance[]); + console.log( + `[API] Updated Invidious instances from Uma. Total: ${DYNAMIC_INVIDIOUS_INSTANCES.length}` + ); + } + } catch (error) { + console.error("[API] Error updating instances from Uma:", error); + } +} + +// Utility functions +export const convertSStoHHMMSS = (seconds: number): string => { + if (seconds < 0) return ""; + if (seconds === Infinity) return "Emergency Mode"; + const hh = Math.floor(seconds / 3600); + seconds %= 3600; + const mm = Math.floor(seconds / 60); + const ss = Math.floor(seconds % 60); + let mmStr = String(mm); + let ssStr = String(ss); + if (mm < 10) mmStr = "0" + mmStr; + if (ss < 10) ssStr = "0" + ssStr; + return (hh > 0 ? hh + ":" : "") + `${mmStr}:${ssStr}`; +}; + +export const numFormatter = (num: number): string => + Intl.NumberFormat("en", { notation: "compact" }).format(num); + +// JioSaavn API helper functions +export const getJioSaavnEndpoint = (endpoint: string, ...params: string[]) => { + return `${API.jiosaavn.base}${endpoint}${params.join("/")}`; +}; + +export const getJioSaavnSearchEndpoint = (query: string) => + `${API.jiosaavn.base}${API.jiosaavn.search}?query=${encodeURIComponent(query)}`; + +export const getJioSaavnSongEndpoint = (songId: string) => + `${API.jiosaavn.base}${API.jiosaavn.songs}/${songId}`; + +export const getJioSaavnAlbumEndpoint = (albumId: string) => + `${API.jiosaavn.base}${API.jiosaavn.albums}?id=${albumId}`; + +export const getJioSaavnArtistEndpoint = ( + artistId: string, + type: "songs" | "albums" = "songs", + page: number = 0 +) => + `${API.jiosaavn.base}${API.jiosaavn.artists}/${artistId}/${type}?page=${page}`; + +export const getJioSaavnPlaylistEndpoint = (query: string) => + `${API.jiosaavn.base}${API.jiosaavn.playlists}?query=${encodeURIComponent(query)}`; + +export const getJioSaavnPlaylistByIdEndpoint = (playlistId: string) => + `${API.jiosaavn.base}/playlists?id=${encodeURIComponent(playlistId)}`; + +export const getJioSaavnArtistSongsEndpoint = ( + artistId: string, + page: number = 0 +) => getJioSaavnArtistEndpoint(artistId, "songs", page); + +export const getJioSaavnArtistAlbumsEndpoint = ( + artistId: string, + page: number = 0 +) => getJioSaavnArtistEndpoint(artistId, "albums", page); + +// Generic fetch with retry logic +export async function fetchWithRetry( + url: string, + options: RequestInit = {}, + maxRetries: number = 3, + delay: number = 1000 +): Promise { + let lastError: Error; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + lastError = error as Error; + console.log(`[API] Attempt ${attempt + 1} failed: ${lastError.message}`); + + if (attempt < maxRetries - 1) { + await new Promise((resolve) => + setTimeout(resolve, delay * (attempt + 1)) + ); + } + } + } + + throw lastError!; +} + +// Instance health check +export async function checkInstanceHealth( + url: string, + timeout: number = 5000 +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + signal: controller.signal, + method: "HEAD", + }); + + clearTimeout(timeoutId); + return response.ok; + } catch (error) { + return false; + } +} + +// Get healthy instances from a list +export async function getHealthyInstances( + instances: string[] +): Promise { + const healthChecks = instances.map(async (instance) => { + const isHealthy = await checkInstanceHealth(instance); + return { instance, isHealthy }; + }); + + const results = await Promise.all(healthChecks); + return results + .filter((result) => result.isHealthy) + .map((result) => result.instance); +} + +// Original streaming functions export async function fetchStreamFromPiped(id: string, api: string) { const res = await fetch(`${api}/streams/${id}`); const data = await res.json(); @@ -16,6 +249,24 @@ export async function fetchStreamFromPiped(id: string, api: string) { return data as Piped; } +/** + * Enhanced fetchStreamFromPiped with fallback support using all Piped instances + */ +export async function fetchStreamFromPipedWithFallback(id: string) { + const errors: string[] = []; + + for (const baseUrl of API.piped) { + try { + return await fetchStreamFromPiped(id, baseUrl); + } catch (error) { + errors.push(`${baseUrl}: ${(error as Error).message}`); + continue; + } + } + + throw new Error(`All Piped instances failed: ${errors.join(", ")}`); +} + export async function fetchStreamFromInvidious(id: string, api: string) { const res = await fetch(`${api}/api/v1/videos/${id}`); const data = await res.json(); @@ -25,31 +276,58 @@ export async function fetchStreamFromInvidious(id: string, api: string) { return data as unknown as Piped; } +/** + * Enhanced fetchStreamFromInvidious with fallback support using all Invidious instances + */ +export async function fetchStreamFromInvidiousWithFallback(id: string) { + const errors: string[] = []; + const instances = + DYNAMIC_INVIDIOUS_INSTANCES.length > 0 + ? DYNAMIC_INVIDIOUS_INSTANCES + : API.invidious; + + for (const baseUrl of instances) { + try { + return await fetchStreamFromInvidious(id, baseUrl); + } catch (error) { + errors.push(`${baseUrl}: ${(error as Error).message}`); + continue; + } + } + + throw new Error(`All Invidious instances failed: ${errors.join(", ")}`); +} + export async function getStreamData( id: string, - prefer: "piped" | "invidious" = "piped", + prefer: "piped" | "invidious" = "piped" ) { - const src = prefer === "piped" ? API.piped : API.invidious; - const list = src.filter(Boolean); - for (const base of list) { - try { - return prefer === "piped" - ? await fetchStreamFromPiped(id, base) - : await fetchStreamFromInvidious(id, base); - } catch (e) { - // try next + try { + // Try preferred source with enhanced fallback + if (prefer === "piped") { + return await fetchStreamFromPipedWithFallback(id); + } else { + return await fetchStreamFromInvidiousWithFallback(id); } - } - // fallback to other source - const alt = prefer === "piped" ? API.invidious : API.piped; - for (const base of alt.filter(Boolean)) { + } catch (error) { + console.log( + `[API] Preferred source (${prefer}) failed, trying fallback...` + ); + + // Fallback to other source try { - return prefer === "piped" - ? await fetchStreamFromInvidious(id, base) - : await fetchStreamFromPiped(id, base); - } catch (e) {} + const alt = prefer === "piped" ? "invidious" : "piped"; + if (alt === "piped") { + return await fetchStreamFromPipedWithFallback(id); + } else { + return await fetchStreamFromInvidiousWithFallback(id); + } + } catch (altError) { + throw new Error( + `Both Piped and Invidious sources failed. Original: ${(error as Error).message}, Fallback: ${(altError as Error).message}` + ); + } } - throw new Error("No sources available"); } export function getBestAudioUrl(piped: Piped) { @@ -58,7 +336,7 @@ export function getBestAudioUrl(piped: Piped) { return undefined; } const sorted = list.sort( - (a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0), + (a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0) ); const best = sorted[0]; return { diff --git a/components/screens/AlbumPlaylistScreen.tsx b/components/screens/AlbumPlaylistScreen.tsx index 29fd05a..6aedaa0 100644 --- a/components/screens/AlbumPlaylistScreen.tsx +++ b/components/screens/AlbumPlaylistScreen.tsx @@ -30,6 +30,25 @@ export const AlbumPlaylistScreen: React.FC = ({ const [albumArtist, setAlbumArtist] = useState(""); const [albumArtUrl, setAlbumArtUrl] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + + // Debug logging for state changes + useEffect(() => { + console.log("[AlbumPlaylistScreen] State updated:", { + albumSongsLength: albumSongs.length, + albumTitle, + albumArtist, + albumArtUrl, + errorMessage, + isLoading, + }); + }, [ + albumSongs, + albumTitle, + albumArtist, + albumArtUrl, + errorMessage, + isLoading, + ]); const [showRenameModal, setShowRenameModal] = useState(false); const [renameValue, setRenameValue] = useState(""); @@ -37,7 +56,7 @@ export const AlbumPlaylistScreen: React.FC = ({ const [showSongActionSheet, setShowSongActionSheet] = useState(false); const [selectedTrack, setSelectedTrack] = useState(null); const [sheetMode, setSheetMode] = useState<"playlist" | "playlist-song">( - "playlist-song", + "playlist-song" ); const sheetTop = useRef(new Animated.Value(SHEET_CLOSED_TOP)).current; const [sheetHeight, setSheetHeight] = useState(SHEET_HEIGHT); @@ -112,7 +131,7 @@ export const AlbumPlaylistScreen: React.FC = ({ animateSheet(target); }, - }), + }) ).current; const closeSongActionSheet = () => { @@ -173,7 +192,7 @@ export const AlbumPlaylistScreen: React.FC = ({ } const updatedTracks = playlist.tracks.filter( - (track) => track.id !== selectedTrack.id, + (track) => track.id !== selectedTrack.id ); const updatedPlaylist = { ...playlist, @@ -246,7 +265,7 @@ export const AlbumPlaylistScreen: React.FC = ({ const unsubscribe = navigation.addListener("focus", () => { if (source === "user-playlist") { console.log( - "[AlbumPlaylistScreen] Screen focused, refreshing playlist", + "[AlbumPlaylistScreen] Screen focused, refreshing playlist" ); loadAlbumSongs(); } @@ -256,6 +275,7 @@ export const AlbumPlaylistScreen: React.FC = ({ }, [albumId, albumName, source, navigation]); const loadAlbumSongs = async () => { + console.log("[AlbumPlaylistScreen] === STARTING loadAlbumSongs ==="); console.log("[AlbumPlaylistScreen] Loading album songs for:", { albumId, albumName, @@ -277,7 +297,7 @@ export const AlbumPlaylistScreen: React.FC = ({ const { searchAPI } = await import("../../modules/searchAPI"); const albumDetails = await searchAPI.getJioSaavnAlbumDetails( albumId, - albumName, + albumName ); if ( @@ -286,7 +306,7 @@ export const AlbumPlaylistScreen: React.FC = ({ albumDetails.songs.length > 0 ) { console.log( - `[AlbumPlaylistScreen] Found ${albumDetails.songs.length} songs in album`, + `[AlbumPlaylistScreen] Found ${albumDetails.songs.length} songs in album` ); const songs = albumDetails.songs.map((song: any) => ({ id: String(song.id), @@ -331,14 +351,14 @@ export const AlbumPlaylistScreen: React.FC = ({ if (playlist) { console.log( - `[AlbumPlaylistScreen] Found playlist with ${playlist.tracks.length} songs`, + `[AlbumPlaylistScreen] Found playlist with ${playlist.tracks.length} songs` ); setAlbumSongs(playlist.tracks); setAlbumTitle(playlist.name); setAlbumArtist( `${playlist.tracks.length} ${ playlist.tracks.length === 1 ? "song" : "songs" - }`, + }` ); // Use first song's thumbnail as album art if available if (playlist.tracks.length > 0 && playlist.tracks[0].thumbnail) { @@ -358,6 +378,99 @@ export const AlbumPlaylistScreen: React.FC = ({ setAlbumArtist(routeArtist || "Unknown Artist"); setErrorMessage("Failed to load playlist"); } + } else if (source === "youtube" || source === "youtubemusic") { + // Handle YouTube/YouTube Music playlists + console.log( + "[AlbumPlaylistScreen] Fetching YouTube/YouTube Music playlist details" + ); + console.log( + `[AlbumPlaylistScreen] Playlist ID: ${albumId}, Source: ${source}` + ); + try { + const { searchAPI } = await import("../../modules/searchAPI"); + console.log( + `[AlbumPlaylistScreen] Calling getYouTubePlaylistDetails for ID: ${albumId}` + ); + const playlistDetails = + await searchAPI.getYouTubePlaylistDetails(albumId); + console.log( + "[AlbumPlaylistScreen] Playlist details response:", + playlistDetails + ); + + if ( + playlistDetails && + playlistDetails.videos && + playlistDetails.videos.length > 0 + ) { + console.log( + `[AlbumPlaylistScreen] SUCCESS: Found ${playlistDetails.videos.length} videos in YouTube playlist` + ); + console.log( + `[AlbumPlaylistScreen] First video:`, + playlistDetails.videos[0] + ); + const songs = playlistDetails.videos.map((video: any) => ({ + id: String(video.id), + title: video.title || "Unknown Title", + artist: video.artist || routeArtist || "Unknown Artist", + duration: video.duration || 0, + thumbnail: video.thumbnail || "", + source: source, + _isYouTube: true, + albumId: albumId, + albumName: albumName, + })); + + console.log( + `[AlbumPlaylistScreen] SUCCESS: Mapped ${songs.length} songs, first song:`, + songs[0] + ); + setAlbumSongs(songs); + setAlbumTitle(playlistDetails.name || albumName); + setAlbumArtist(routeArtist || "Various Artists"); + setAlbumArtUrl(playlistDetails.thumbnail || ""); + setErrorMessage(""); // Clear any previous error message + console.log("[AlbumPlaylistScreen] State updated successfully"); + } else { + console.error( + "[AlbumPlaylistScreen] FAIL: No videos found in YouTube playlist" + ); + console.error( + `[AlbumPlaylistScreen] playlistDetails:`, + playlistDetails + ); + + // Enhanced error message based on the response + let errorMsg = "No videos found in this playlist"; + if (!playlistDetails) { + errorMsg = + "Unable to fetch playlist. The service may be temporarily unavailable."; + } else if (!playlistDetails.videos) { + errorMsg = "This playlist appears to be empty or unavailable."; + } + + setAlbumSongs([]); + setAlbumTitle(albumName); + setAlbumArtist(routeArtist || "Unknown Artist"); + setErrorMessage(errorMsg); + console.log( + "[AlbumPlaylistScreen] State set to empty with error message" + ); + } + } catch (error) { + console.error( + "[AlbumPlaylistScreen] ERROR: Exception while loading YouTube playlist:", + error + ); + setAlbumSongs([]); + setAlbumTitle(albumName); + setAlbumArtist(routeArtist || "Unknown Artist"); + setErrorMessage( + "Failed to load YouTube playlist. Please check your internet connection or try again later." + ); + console.log("[AlbumPlaylistScreen] State set to empty due to error"); + } } else { // For other sources, we might need different API calls // For now, show empty state for non-JioSaavn albums @@ -374,7 +487,7 @@ export const AlbumPlaylistScreen: React.FC = ({ setErrorMessage( error instanceof Error ? `Failed to load album: ${error.message}` - : "Failed to load album tracks. This album may not be available or the service is temporarily unavailable.", + : "Failed to load album tracks. This album may not be available or the service is temporarily unavailable." ); } finally { setIsLoading(false); diff --git a/components/screens/ArtistScreen.tsx b/components/screens/ArtistScreen.tsx index a8a2ac4..259b82c 100644 --- a/components/screens/ArtistScreen.tsx +++ b/components/screens/ArtistScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { View, Text, @@ -10,11 +10,18 @@ import { Dimensions, } from "react-native"; import styled from "styled-components/native"; -import { Ionicons } from "@expo/vector-icons"; +import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { usePlayer } from "../../contexts/PlayerContext"; import { SafeArea } from "../SafeArea"; import { t } from "../../utils/localization"; +import { + getJioSaavnArtistEndpoint, + getJioSaavnArtistSongsEndpoint, + getJioSaavnArtistAlbumsEndpoint, + fetchWithRetry, + API, +} from "../core/api"; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); const HEADER_HEIGHT = screenHeight * 0.45; @@ -62,12 +69,12 @@ const BackButton = styled.TouchableOpacity` z-index: 10; `; -const ArtistName = styled.Text` +const ArtistName = styled.Text<{ fontSize: number }>` color: #fff; - font-size: 64px; + font-size: ${(props) => props.fontSize}px; margin-bottom: 8px; font-family: GoogleSansBold; - line-height: 68px; + line-height: ${(props) => props.fontSize + 4}px; `; const MonthlyListeners = styled.Text` @@ -77,6 +84,12 @@ const MonthlyListeners = styled.Text` line-height: 20px; `; +const VerifiedBadge = styled.View` + margin-left: 8px; + align-self: flex-end; + margin-bottom: 8px; +`; + const ContentContainer = styled.View` flex: 1; background-color: #000; @@ -222,8 +235,9 @@ const AlbumItem = styled.TouchableOpacity` const AlbumImage = styled.Image` width: 100%; aspect-ratio: 1; - border-radius: 8px; + border-radius: 12px; background-color: #333; + z-index: 3; `; const AlbumTitle = styled.Text` @@ -315,6 +329,7 @@ interface Artist { name: string; image: string; monthlyListeners?: number; + verified?: boolean; } interface Song { @@ -331,6 +346,7 @@ interface Album { title: string; year: string; thumbnail: string; + videoCount?: string; // Optional video/song count } const ArtistScreen: React.FC = ({ navigation, route }) => { @@ -346,17 +362,42 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { "songs" | "albums" | "playlists" >("songs"); const [isYouTubeChannel, setIsYouTubeChannel] = useState(false); + const [artistNameFontSize, setArtistNameFontSize] = useState(64); const { artistId, artistName } = route.params; + // Function to calculate font size based on artist name length + const calculateFontSize = useCallback((name: string): number => { + if (!name) return 64; + + const baseFontSize = 64; + const minFontSize = 32; + const maxLengthForBaseSize = 12; // characters that fit in 1 line at 64px + + // Estimate characters per line (this is approximate and depends on character width) + const charsPerLine = 12; // Conservative estimate for 1 line + + if (name.length <= charsPerLine) { + return baseFontSize; + } + + // Decrease font size proportionally for longer names + const lengthRatio = name.length / charsPerLine; + const newSize = Math.max(minFontSize, baseFontSize / (lengthRatio * 0.7)); + + return Math.round(newSize); + }, []); + // Function to fetch YouTube albums for a channel const fetchYouTubeAlbums = async (channelId: string) => { try { // First, try to get the channel data to access tabs - const channelResponse = await fetch( - `https://api.piped.private.coffee/channel/${channelId}` + const channelData = await fetchWithRetry( + `${API.piped[0]}/channel/${channelId}`, + {}, + 3, + 1000 ); - const channelData = await channelResponse.json(); console.log("Channel tabs data for albums:", channelData.tabs); @@ -369,30 +410,27 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { // Use the correct GET format for the tabs endpoint const albumsTabData = JSON.parse(albumsTab.data); const encodedData = encodeURIComponent(JSON.stringify(albumsTabData)); - const albumsResponse = await fetch( - `https://api.piped.private.coffee/channels/tabs?data=${encodedData}` + const albumsData = await fetchWithRetry( + `${API.piped[0]}/channels/tabs?data=${encodedData}`, + {}, + 3, + 1000 ); - - if (albumsResponse.ok) { - const albumsData = await albumsResponse.json(); - console.log("Albums data fetched successfully:", albumsData); - - // Process the albums data - if (albumsData.content && Array.isArray(albumsData.content)) { - return albumsData.content.map((album: any, index: number) => ({ - id: album.url || `album_${index}`, - title: - album.name || - album.title || - t("screens.artist.unknown_album"), - thumbnail: - album.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Album", - year: album.year || "", - type: "album", - songCount: album.videos || 0, - })); - } + console.log("Albums data fetched successfully:", albumsData); + + // Process the albums data + if (albumsData.content && Array.isArray(albumsData.content)) { + return albumsData.content.map((album: any, index: number) => ({ + id: album.url || `album_${index}`, + title: + album.name || album.title || t("screens.artist.unknown_album"), + thumbnail: + album.thumbnail || + "https://via.placeholder.com/160x160/333/ffffff?text=Album", + year: album.year || "", + type: "album", + videoCount: album.videos ? `${album.videos} videos` : undefined, + })); } } catch (apiError) { console.log( @@ -402,34 +440,8 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } } - // Fallback: Create albums from relatedStreams data by grouping by upload date - const relatedStreams = channelData.relatedStreams || []; - - // Group videos by upload year to create "albums" - const yearGroups = relatedStreams.reduce((groups: any, video: any) => { - const year = new Date(video.uploaded).getFullYear(); - if (!groups[year]) { - groups[year] = []; - } - groups[year].push(video); - return groups; - }, {}); - - // Create albums from year groups - const albums = Object.keys(yearGroups) - .map((year, index) => ({ - id: `album_${year}`, - title: `${year} Releases`, - thumbnail: - yearGroups[year][0]?.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Album", - year: year, - type: "album", - songCount: yearGroups[year].length, - })) - .sort((a, b) => parseInt(b.year) - parseInt(a.year)); - - return albums; + // Return empty array if no real albums are found + return []; } catch (error) { console.error("Error fetching YouTube albums:", error); return []; @@ -440,10 +452,12 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { const fetchYouTubePlaylists = async (channelId: string) => { try { // First, try to get the channel data to access tabs - const channelResponse = await fetch( - `https://api.piped.private.coffee/channel/${channelId}` + const channelData = await fetchWithRetry( + `${API.piped[0]}/channel/${channelId}`, + {}, + 3, + 1000 ); - const channelData = await channelResponse.json(); console.log("Channel tabs data:", channelData.tabs); @@ -458,31 +472,50 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { const encodedData = encodeURIComponent( JSON.stringify(playlistsTabData) ); - const playlistsResponse = await fetch( - `https://api.piped.private.coffee/channels/tabs?data=${encodedData}` + const playlistsData = await fetchWithRetry( + `${API.piped[0]}/channels/tabs?data=${encodedData}`, + {}, + 3, + 1000 ); - - if (playlistsResponse.ok) { - const playlistsData = await playlistsResponse.json(); - console.log("Playlists data fetched successfully:", playlistsData); - - // Process the playlists data - if (playlistsData.content && Array.isArray(playlistsData.content)) { - return playlistsData.content.map( - (playlist: any, index: number) => ({ - id: playlist.url || `playlist_${index}`, - title: - playlist.name || - playlist.title || - t("screens.artist.unknown_playlist"), - thumbnail: - playlist.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Playlist", - videoCount: playlist.videos || playlist.videoCount || 0, - type: "playlist", - }) + console.log("Playlists data fetched successfully:", playlistsData); + + // Process the playlists data + if (playlistsData.content && Array.isArray(playlistsData.content)) { + return playlistsData.content.map((playlist: any, index: number) => { + // Extract playlist ID from URL (e.g., "/playlist?list=ABC123" -> "ABC123") + let playlistId = `playlist_${index}`; + if (playlist.url) { + const match = playlist.url.match(/[?&]list=([^&]+)/); + if (match && match[1]) { + playlistId = match[1]; + } else { + playlistId = playlist.url; + } + } + + console.log( + `[ArtistScreen] Extracted playlist ID: ${playlistId} from URL: ${playlist.url}` ); - } + + return { + id: playlistId, + title: + playlist.name || + playlist.title || + t("screens.artist.unknown_playlist"), + thumbnail: + playlist.thumbnail || + "https://via.placeholder.com/160x160/333/ffffff?text=Playlist", + videoCount: + playlist.videos && playlist.videos > 0 + ? playlist.videos + : playlist.videoCount && playlist.videoCount > 0 + ? playlist.videoCount + : 0, + type: "playlist", + }; + }); } } catch (apiError) { console.log( @@ -492,46 +525,8 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } } - // Fallback: Create playlists from relatedStreams data by grouping similar content - const relatedStreams = channelData.relatedStreams || []; - - // Group videos by common themes or upload dates - const playlists = [ - { - id: "recent_videos", - title: t("screens.artist.recent_videos"), - thumbnail: - relatedStreams[0]?.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Recent", - videoCount: Math.min(relatedStreams.length, 10), - type: "playlist", - }, - { - id: "popular_videos", - title: t("screens.artist.popular_videos"), - thumbnail: - relatedStreams.find((v: any) => v.views > 1000000)?.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Popular", - videoCount: - relatedStreams.filter((v: any) => v.views > 1000000).length || 5, - type: "playlist", - }, - { - id: "official_audio", - title: t("screens.artist.official_audio"), - thumbnail: - relatedStreams.find((v: any) => v.title?.includes("Official Audio")) - ?.thumbnail || - "https://via.placeholder.com/160x160/333/ffffff?text=Audio", - videoCount: - relatedStreams.filter((v: any) => - v.title?.includes("Official Audio") - ).length || 5, - type: "playlist", - }, - ].filter((playlist) => playlist.videoCount > 0); - - return playlists; + // Return empty array if no real playlists are found + return []; } catch (error) { console.error("Error fetching YouTube playlists:", error); return []; @@ -557,20 +552,23 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { if (isYouTubeChannel) { // Use Piped API for YouTube channels - const channelResponse = await fetch( - `https://api.piped.private.coffee/channel/${artistId}` + const channelData = await fetchWithRetry( + `${API.piped[0]}/channel/${artistId}`, + {}, + 3, + 1000 ); - const channelData = await channelResponse.json(); console.log("YouTube channel API response:", channelData); // Process YouTube channel data const processedArtist: Artist = { id: artistId, - name: channelData.name || artistName, + name: (channelData.name || artistName)?.replace(/\s*-\s*Topic$/i, ""), image: channelData.avatarUrl || "https://via.placeholder.com/500x500/1a1a1a/ffffff?text=Artist", monthlyListeners: channelData.subscribers || 0, + verified: channelData.verified || false, }; // Process channel videos as songs - show all available videos @@ -601,25 +599,19 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { setPopularSongs(processedSongs); setAlbums(processedAlbums); setPlaylists(processedPlaylists); - setLoading(false); - return; - - // YouTube channels don't have traditional albums, but we could show playlists if needed - // For now, we'll leave albums empty for YouTube channels - - setArtistData(processedArtist); - setPopularSongs(processedSongs); - setAlbums(processedAlbums); + setArtistNameFontSize(calculateFontSize(processedArtist.name)); setLoading(false); return; } // Original JioSaavn API logic for non-YouTube artists // Fetch artist info - const artistResponse = await fetch( - `https://streamifyjiosaavn.vercel.app/api/artists/${artistId}` + const artistInfo = await fetchWithRetry( + getJioSaavnArtistEndpoint(artistId), + {}, + 3, + 1000 ); - const artistInfo = await artistResponse.json(); console.log("Artist info API response:", artistInfo); // Validate artist response @@ -629,28 +621,32 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } // Fetch artist songs - const songsResponse = await fetch( - `https://streamifyjiosaavn.vercel.app/api/artists/${artistId}/songs?page=0` - ); let songsData; try { - songsData = await songsResponse.json(); + songsData = await fetchWithRetry( + getJioSaavnArtistSongsEndpoint(artistId, 0), + {}, + 3, + 1000 + ); console.log("Songs API response:", songsData); } catch (e) { - console.warn("Failed to parse songs JSON, using empty array"); + console.warn("Failed to fetch songs, using empty array", e); songsData = []; } // Fetch artist albums - const albumsResponse = await fetch( - `https://streamifyjiosaavn.vercel.app/api/artists/${artistId}/albums?page=0` - ); let albumsData; try { - albumsData = await albumsResponse.json(); + albumsData = await fetchWithRetry( + getJioSaavnArtistAlbumsEndpoint(artistId, 0), + {}, + 3, + 1000 + ); console.log("Albums API response:", albumsData); } catch (e) { - console.warn("Failed to parse albums JSON, using empty array"); + console.warn("Failed to fetch albums, using empty array", e); albumsData = []; } @@ -691,7 +687,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { const processedArtist: Artist = { id: artistId, - name: artistData.name || artistName, + name: (artistData.name || artistName)?.replace(/\s*-\s*Topic$/i, ""), image: getBestQualityImage(artistData.image), monthlyListeners: artistData.followers || artistData.followerCount || 0, }; @@ -741,12 +737,16 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { album.image?.[0]?.url || album.thumbnail || "https://via.placeholder.com/160x160/333/ffffff?text=Album", + videoCount: album.songCount + ? `${album.songCount} songs` + : undefined, }; }); setArtistData(processedArtist); setPopularSongs(processedSongs); setAlbums(processedAlbums); + setArtistNameFontSize(calculateFontSize(processedArtist.name)); } catch (err) { console.error("Error fetching artist data:", err); setError("Failed to load artist data. Please try again."); @@ -913,7 +913,25 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { - {artistData.name} + + + {artistData.name} + {artistData.verified && ( + + + + )} + + {artistData.monthlyListeners && ( {formatMonthlyListeners(artistData.monthlyListeners)}{" "} @@ -935,13 +953,6 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { : t("screens.artist.follow")} - - - @@ -1025,7 +1036,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { > {album.title} - {album.year} + {album.videoCount || album.year} ))} @@ -1057,7 +1068,11 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { {playlist.title} - {playlist.videoCount} videos + + {playlist.videoCount && playlist.videoCount > 0 + ? `${playlist.videoCount} videos` + : "No videos"} + ))} diff --git a/components/screens/HomeScreen.tsx b/components/screens/HomeScreen.tsx index 5361363..7bc3cbd 100644 --- a/components/screens/HomeScreen.tsx +++ b/components/screens/HomeScreen.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { ScrollView } from "react-native"; import styled from "styled-components/native"; -import StreamItem from "../StreamItem"; +import { default as StreamItem } from "../StreamItem"; import { SafeArea } from "../SafeArea"; import { LinearGradient } from "expo-linear-gradient"; import { usePlayer } from "../../contexts/PlayerContext"; @@ -9,43 +9,36 @@ import { FeaturedPlaylistSkeleton, CategoryPlaylistSkeleton, } from "../SkeletonLoader"; +import { + getJioSaavnPlaylistEndpoint, + getJioSaavnPlaylistByIdEndpoint, + fetchWithRetry, +} from "../core/api"; // API endpoints for your Lowkey Backend const CATEGORY_APIS = { - indie: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=indie", - edm: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=edm", - metal: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=metal", - punk: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=punk", - party: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=party", - jazz: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=jazz", - love: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=love", - rap: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=rap", - workout: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=workout", - pop: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=pop", - hiphop: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=hiphop", - rock: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=rock", - melody: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=melody", - lofi: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=lofi", - chill: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=chill", - focus: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=focus", - instrumental: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=instrumental", - folk: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=folk", - devotional: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=devotional", - ambient: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=ambient", - sleep: - "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=sleep", - soul: "https://streamifyjiosaavn.vercel.app/api/search/playlists?query=soul", + indie: getJioSaavnPlaylistEndpoint("indie"), + edm: getJioSaavnPlaylistEndpoint("edm"), + metal: getJioSaavnPlaylistEndpoint("metal"), + punk: getJioSaavnPlaylistEndpoint("punk"), + party: getJioSaavnPlaylistEndpoint("party"), + jazz: getJioSaavnPlaylistEndpoint("jazz"), + love: getJioSaavnPlaylistEndpoint("love"), + rap: getJioSaavnPlaylistEndpoint("rap"), + workout: getJioSaavnPlaylistEndpoint("workout"), + pop: getJioSaavnPlaylistEndpoint("pop"), + hiphop: getJioSaavnPlaylistEndpoint("hiphop"), + rock: getJioSaavnPlaylistEndpoint("rock"), + melody: getJioSaavnPlaylistEndpoint("melody"), + lofi: getJioSaavnPlaylistEndpoint("lofi"), + chill: getJioSaavnPlaylistEndpoint("chill"), + focus: getJioSaavnPlaylistEndpoint("focus"), + instrumental: getJioSaavnPlaylistEndpoint("instrumental"), + folk: getJioSaavnPlaylistEndpoint("folk"), + devotional: getJioSaavnPlaylistEndpoint("devotional"), + ambient: getJioSaavnPlaylistEndpoint("ambient"), + sleep: getJioSaavnPlaylistEndpoint("sleep"), + soul: getJioSaavnPlaylistEndpoint("soul"), }; // Featured playlist IDs @@ -270,10 +263,12 @@ export default function HomeScreen({ navigation }: any) { // Fetch playlist data for a specific category const fetchCategoryPlaylists = async (category: string) => { try { - const response = await fetch( + const data = await fetchWithRetry( CATEGORY_APIS[category as keyof typeof CATEGORY_APIS], + {}, + 3, + 1000 ); - const data = await response.json(); if (data.success && data.data?.results) { const playlists = data.data.results.slice(0, 6); @@ -292,10 +287,16 @@ export default function HomeScreen({ navigation }: any) { // Fetch all featured playlists in parallel for faster loading const playlistPromises = FEATURED_PLAYLIST_IDS.map(async (playlistId) => { try { - const response = await fetch( - `https://streamifyjiosaavn.vercel.app/api/playlists?id=${playlistId}`, + const data = await fetchWithRetry( + getJioSaavnPlaylistByIdEndpoint(playlistId), + {}, + 3, + 1000 + ); + console.log( + `[DEBUG] Playlist ${playlistId} response:`, + JSON.stringify(data, null, 2) ); - const data = await response.json(); if (data.success && data.data) { return data.data; } @@ -303,7 +304,7 @@ export default function HomeScreen({ navigation }: any) { } catch (error) { console.error( `Failed to fetch featured playlist ${playlistId}:`, - error, + error ); return null; } @@ -314,10 +315,85 @@ export default function HomeScreen({ navigation }: any) { // Filter out any null results (failed fetches) const validPlaylists = featuredData.filter( - (playlist) => playlist !== null, + (playlist) => playlist !== null + ); + + console.log(`[DEBUG] Valid playlists count: ${validPlaylists.length}`); + console.log( + `[DEBUG] First playlist structure:`, + JSON.stringify(validPlaylists[0], null, 2) ); - setFeaturedPlaylists(validPlaylists); + // Transform playlist data to match expected interface + const transformedPlaylists = validPlaylists.map((playlist) => { + try { + // Handle different possible API response formats + const playlistData = { + id: playlist.id || playlist.playlistId || "", + name: + playlist.name || + playlist.title || + playlist.playlistName || + "Unknown Playlist", + type: playlist.type || "playlist", + image: + playlist.image || playlist.images || playlist.imageUrl + ? Array.isArray(playlist.image) + ? playlist.image + : Array.isArray(playlist.images) + ? playlist.images + : playlist.imageUrl + ? [{ quality: "500x500", url: playlist.imageUrl }] + : [] + : [], + url: playlist.url || playlist.permaUrl || "", + songCount: + playlist.songCount || + playlist.songsCount || + playlist.songs?.length || + 0, + language: playlist.language || "Unknown", + explicitContent: playlist.explicitContent || false, + }; + + // If no images found, add a default one + if (playlistData.image.length === 0) { + playlistData.image = [ + { + quality: "500x500", + url: "https://via.placeholder.com/500x500.png?text=No+Image", + }, + ]; + } + + return playlistData; + } catch (error) { + console.error(`[DEBUG] Error transforming playlist:`, error); + // Return a default playlist structure if transformation fails + return { + id: "error", + name: "Error Loading Playlist", + type: "playlist", + image: [ + { + quality: "500x500", + url: "https://via.placeholder.com/500x500.png?text=Error", + }, + ], + url: "", + songCount: 0, + language: "Unknown", + explicitContent: false, + }; + } + }); + + console.log( + `[DEBUG] Transformed first playlist:`, + JSON.stringify(transformedPlaylists[0], null, 2) + ); + + setFeaturedPlaylists(transformedPlaylists); } catch (error) { console.error("Failed to fetch featured playlists:", error); } finally { @@ -402,16 +478,33 @@ export default function HomeScreen({ navigation }: any) { }; const getPlaylistImageSource = (playlist: Playlist) => { - const highQualityImage = playlist.image.find( - (img) => img.quality === "500x500", - ); - const imageUrl = highQualityImage?.url || playlist.image[0]?.url; - - // Return the image URL as URI or fallback image source - if (imageUrl) { - return { uri: imageUrl }; + try { + console.log( + `[DEBUG] Playlist ${playlist.id} image data:`, + JSON.stringify(playlist.image, null, 2) + ); + + // Ensure playlist.image is an array + const imageArray = Array.isArray(playlist.image) ? playlist.image : []; + + const highQualityImage = imageArray.find( + (img) => img && img.quality === "500x500" + ); + const imageUrl = highQualityImage?.url || imageArray[0]?.url; + + // Return the image URL as URI or fallback image source + if (imageUrl) { + return { uri: imageUrl }; + } else { + return require("../../assets/StreamifyLogo.png"); + } + } catch (error) { + console.error( + `[DEBUG] Error in getPlaylistImageSource for playlist ${playlist.id}:`, + error + ); + return require("../../assets/StreamifyLogo.png"); } - return require("../../assets/StreamifyLogo.png"); }; const formatSongCount = (count: number) => { diff --git a/components/screens/SearchScreen.tsx b/components/screens/SearchScreen.tsx index bbc5a5c..5627aaf 100644 --- a/components/screens/SearchScreen.tsx +++ b/components/screens/SearchScreen.tsx @@ -19,7 +19,7 @@ import { } from "react-native"; import styled from "styled-components/native"; import { Ionicons } from "@expo/vector-icons"; -import StreamItem from "../StreamItem"; +import { default as StreamItem } from "../StreamItem"; import { searchAPI } from "../../modules/searchAPI"; import { SafeArea } from "../SafeArea"; import { usePlayer } from "../../contexts/PlayerContext"; @@ -39,16 +39,15 @@ interface SearchSectionProps { setShowSuggestions: (show: boolean) => void; playTrack: (track: any, playlist: any[], index: number) => Promise; searchResults: any[]; + selectedFilter?: string; + selectedSource?: string; } // Helper function for formatting duration const formatDuration = (seconds: number, source?: string): string => { if (seconds === 0) { - // Don't show LIVE for JioSaavn when duration is 0 - if (source === "jiosaavn") { - return ""; - } - return t("search.live"); + // Never show LIVE badge for any source when duration is 0 + return ""; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -265,8 +264,12 @@ const SearchSection = memo( setShowSuggestions, playTrack, searchResults, + selectedFilter, + selectedSource, }: SearchSectionProps) => { - if (items.length === 0) return null; + if (items.length === 0) { + return null; + } const renderItem = useCallback( ({ item }: { item: any }) => ( @@ -279,12 +282,26 @@ const SearchSection = memo( title={item.title} author={item.author} duration={formatDuration(parseInt(item.duration) || 0, item.source)} - views={item.source === "jiosaavn" ? undefined : item.views} + views={ + item.source === "jiosaavn" || + (selectedSource === "youtubemusic" && + selectedFilter === "songs") || + item.views === "-1" || + item.views === "0" + ? undefined + : item.views + } + videoCount={item.videoCount} uploaded={item.uploaded} thumbnailUrl={item.thumbnailUrl} isAlbum={!!item.albumId} albumYear={item.albumYear} source={item.source} + type={item.type} + channelDescription={item.description} + verified={item.verified} + searchFilter={selectedFilter} + searchSource={selectedSource} /> ), @@ -335,8 +352,13 @@ interface SearchResult { views?: string; img?: string; thumbnailUrl?: string; - source?: "youtube" | "soundcloud" | "jiosaavn" | "youtube_channel"; - type?: "song" | "album" | "artist" | "unknown"; + source?: + | "youtube" + | "soundcloud" + | "jiosaavn" + | "youtube_channel" + | "youtubemusic"; + type?: "song" | "album" | "artist" | "playlist" | "unknown"; albumId?: string; albumName?: string; albumYear?: string; @@ -355,13 +377,12 @@ const searchFilters = [ { value: "videos", label: "Videos" }, { value: "channels", label: "Channels" }, { value: "playlists", label: "Playlists" }, - { value: "date", label: "Latest" }, - { value: "views", label: "Popular" }, ]; const youtubeMusicFilters = [ - { value: "", label: "All" }, + { value: "songs", label: "Songs" }, { value: "videos", label: "Videos" }, + { value: "albums", label: "Albums" }, { value: "playlists", label: "Playlists" }, { value: "channels", label: "Artists" }, ]; @@ -454,6 +475,7 @@ export default function SearchScreen({ navigation }: any) { page: 1, hasMore: true, isLoadingMore: false, + nextpage: null as string | null, }); const retryRef = useRef({ @@ -561,6 +583,7 @@ export default function SearchScreen({ navigation }: any) { page: 1, hasMore: true, isLoadingMore: false, + nextpage: null, }; // Reset retry counter for new searches retryRef.current.attempts = 0; @@ -603,37 +626,58 @@ export default function SearchScreen({ navigation }: any) { console.log(t("search.spotify_not_implemented")); results = []; } else if (selectedSource === "youtubemusic") { - // YouTube Music Search - results = await searchAPI.searchWithYouTubeMusic( + const youtubeMusicResponse = await searchAPI.searchWithYouTubeMusic( queryToUse, selectedFilter, paginationRef.current.page, - 20 + 20, + paginationRef.current.nextpage || undefined ); + if (youtubeMusicResponse.nextpage) { + paginationRef.current.nextpage = youtubeMusicResponse.nextpage; + } else { + paginationRef.current.nextpage = null; + } + results = youtubeMusicResponse.items || []; } else { // YouTube (Default) - results = - selectedFilter === "date" || selectedFilter === "views" - ? await searchAPI.searchWithInvidious( - queryToUse, - selectedFilter, - paginationRef.current.page, - 20 - ) - : await searchAPI.searchWithPiped( - queryToUse, - selectedFilter, - paginationRef.current.page, - 20 - ); + if (selectedFilter === "date" || selectedFilter === "views") { + results = await searchAPI.searchWithInvidious( + queryToUse, + selectedFilter, + paginationRef.current.page, + 20 + ); + } else { + // Handle Piped API response which returns {items, nextpage} + const pipedResponse = await searchAPI.searchWithPiped( + queryToUse, + selectedFilter, + paginationRef.current.page, + 20, + paginationRef.current.nextpage || undefined + ); + // Extract nextpage token for future pagination + if (pipedResponse.nextpage) { + paginationRef.current.nextpage = pipedResponse.nextpage; + console.log( + `[Search] Extracted nextpage token: ${pipedResponse.nextpage.substring(0, 50)}...` + ); + } else { + paginationRef.current.nextpage = null; + console.log("[Search] No nextpage token in response"); + } + results = pipedResponse.items; + } } console.log(`[Search] API returned ${results.length} results`); console.log( "[Search] First few API results:", - results - .slice(0, 3) - .map((item) => ({ id: item.videoId || item.id, title: item.title })) + results.slice(0, 3).map((item) => ({ + id: item.videoId || item.id, + title: item.title, + })) ); // Common formatter (only format if not already formatted) @@ -652,7 +696,15 @@ export default function SearchScreen({ navigation }: any) { // Apply display formatting formattedResults = formattedResults.map((r) => ({ ...r, - views: r.views ? shortCount(r.views) + " views" : undefined, + views: + selectedSource === "youtubemusic" && + (selectedFilter === "songs" || + selectedFilter === "all" || + r.type === "song") + ? undefined + : r.views + ? shortCount(r.views) + " views" + : undefined, // Remove YouTube-specific noise from upload string uploaded: r.uploaded && typeof r.uploaded === "string" @@ -669,10 +721,12 @@ export default function SearchScreen({ navigation }: any) { (item.source === "youtube" || item.source === "youtube_channel") && (item.href?.includes("/channel/") || item.type === "channel") - ) - return 0; // channels - if (item.type === "playlist" || item.href?.includes("&list=")) - return 2; // playlists + ) { + return 0; + } // channels + if (item.type === "playlist" || item.href?.includes("&list=")) { + return 2; + } // playlists return 1; // videos } else { // youtubemusic @@ -680,10 +734,12 @@ export default function SearchScreen({ navigation }: any) { (item.source === "youtubemusic" || item.source === "youtube_channel") && (item.href?.includes("/channel/") || item.type === "channel") - ) - return 0; // artists/channels - if (item.type === "playlist" || item.href?.includes("&list=")) - return 2; // playlists + ) { + return 0; + } // artists/channels + if (item.type === "playlist" || item.href?.includes("&list=")) { + return 2; + } // playlists return 1; // videos } }; @@ -768,9 +824,10 @@ export default function SearchScreen({ navigation }: any) { return [...prev, ...newItems]; }); - // Check if we got fewer results than expected (indicating end of results) - const hasMore = - formattedResults.length >= 20 && formattedResults.length > 0; // Assume 20 items per page, but only if we got results + // Check if we have a nextpage token for pagination (Piped API uses nextpage tokens) + const hasMore = paginationRef.current.nextpage + ? true + : formattedResults.length >= 20 && formattedResults.length > 0; setHasMoreResults(hasMore); paginationRef.current.hasMore = hasMore; paginationRef.current.isLoadingMore = false; @@ -782,8 +839,9 @@ export default function SearchScreen({ navigation }: any) { ); } else { setSearchResults(formattedResults); - const hasMore = - formattedResults.length >= 20 && formattedResults.length > 0; + const hasMore = paginationRef.current.nextpage + ? true + : formattedResults.length >= 20 && formattedResults.length > 0; setHasMoreResults(hasMore); paginationRef.current.hasMore = hasMore; console.log( @@ -870,11 +928,10 @@ export default function SearchScreen({ navigation }: any) { // Set appropriate default filter for each source if (selectedSource === "soundcloud") { setSelectedFilter("tracks"); - } else if ( - selectedSource === "youtube" || - selectedSource === "youtubemusic" - ) { - setSelectedFilter(""); // "All" filter + } else if (selectedSource === "youtubemusic") { + setSelectedFilter("songs"); // Default to "Songs" for YouTube Music + } else if (selectedSource === "youtube") { + setSelectedFilter(""); // "All" filter for YouTube } else { setSelectedFilter(""); // Default to "All" for other sources } @@ -897,8 +954,9 @@ export default function SearchScreen({ navigation }: any) { // Memoized filtering functions for better performance const filteredResults = useMemo(() => { - if (!searchResults.length) + if (!searchResults.length) { return { topResults: [], artists: [], albums: [], songs: [] }; + } // Pre-calculate collaboration check for better performance const isSearchingForIndividualArtist = @@ -919,7 +977,9 @@ export default function SearchScreen({ navigation }: any) { const artists = searchResults .filter((item) => { - if (item.type !== "artist") return false; + if (item.type !== "artist") { + return false; + } // Skip collaboration artists for individual searches if (isSearchingForIndividualArtist && isCollaboration(item.title)) { @@ -934,19 +994,39 @@ export default function SearchScreen({ navigation }: any) { const aIsExact = a.title.toLowerCase().trim() === queryLower; const bIsExact = b.title.toLowerCase().trim() === queryLower; - if (aIsExact && !bIsExact) return -1; - if (!aIsExact && bIsExact) return 1; + if (aIsExact && !bIsExact) { + return -1; + } + if (!aIsExact && bIsExact) { + return 1; + } return 0; }); - const albums = searchResults.filter((item) => item.type === "album"); + const albums = searchResults.filter((item) => { + // Filter out albums for YouTube and YouTube Music sources + if ( + item.type === "album" && + (item.source === "youtube" || item.source === "youtubemusic") + ) { + return false; + } + return item.type === "album"; + }); + const playlists = searchResults.filter( + (item) => item.type === "playlist" || item.href?.includes("&list=") + ); const songs = searchResults.filter((item) => { // Filter songs by type - combine both conditions in one filter - if (item.type && item.type !== "song") return false; + if (item.type && item.type !== "song") { + return false; + } // For items without explicit type, check if they have duration (indicating they're songs) - if (!item.type && !item.duration) return false; + if (!item.type && !item.duration) { + return false; + } // Skip collaboration songs for individual artist searches if ( @@ -965,6 +1045,7 @@ export default function SearchScreen({ navigation }: any) { console.log("[Filter] Top results:", topResults.length); console.log("[Filter] Artists:", artists.length); console.log("[Filter] Albums:", albums.length); + console.log("[Filter] Playlists:", playlists.length); console.log("[Filter] Songs:", songs.length); if (searchResults.length > 0) { console.log( @@ -975,7 +1056,7 @@ export default function SearchScreen({ navigation }: any) { ); } - return { topResults, artists, albums, songs }; + return { topResults, artists, albums, playlists, songs }; }, [searchResults, searchQuery]); // Optimized item press handlers @@ -1048,6 +1129,15 @@ export default function SearchScreen({ navigation }: any) { albumArtist: item.author, source: item.source, }); + } else if (item.source === "youtube" || item.source === "youtubemusic") { + // Handle YouTube/YouTube Music playlists + navigation.navigate("AlbumPlaylist", { + albumId: item.id, + albumName: item.title, + albumArtist: item.author, + source: item.source, + videoCount: item.videoCount, + }); } }, [navigation] @@ -1083,9 +1173,11 @@ export default function SearchScreen({ navigation }: any) { t("screens.artist.unknown_title"), artist: song.artists?.primary - ?.map((artist: any) => artist.name) + ?.map((artist: any) => + artist.name?.replace(/\s*-\s*Topic$/i, "") + ) .join(", ") || - song.singers || + song.singers?.replace(/\s*-\s*Topic$/i, "") || t("screens.artist.unknown_artist"), duration: song.duration || 0, thumbnail: @@ -1444,11 +1536,28 @@ export default function SearchScreen({ navigation }: any) { /> )} - {/* Albums Section */} - {filteredResults.albums.length > 0 && ( + {/* Albums Section - Hide for YouTube and YouTube Music */} + {filteredResults.albums.length > 0 && + selectedSource !== "youtube" && + selectedSource !== "youtubemusic" && ( + + )} + + {/* Playlists Section */} + {filteredResults.playlists.length > 0 && ( )} - {/* Load More Button - Only at the end of all content */} - {hasMoreResults && ( + {/* Load More Button or End of Results - Only at the end of all content */} + {!hasMoreResults && searchResults.length > 0 ? ( - {isLoadingMore ? ( - - ) : ( - - - Load More - - - )} + + End of search results + + ) : ( + hasMoreResults && ( + + {isLoadingMore ? ( + + ) : ( + + + Load More + + + )} + + ) )} )} diff --git a/components/screens/SettingsScreen.tsx b/components/screens/SettingsScreen.tsx index a181396..bd06df5 100644 --- a/components/screens/SettingsScreen.tsx +++ b/components/screens/SettingsScreen.tsx @@ -59,7 +59,10 @@ const SettingLeft = styled.View` flex-direction: row; align-items: center; flex: 1; - padding-left: ${props => props.hasIcon ? '8px' : '0px'}; /* 24px (icon width) + 12px (margin) = 36px */ + padding-left: ${(props) => + props.hasIcon + ? '8px' + : '0px'}; /* 24px (icon width) + 12px (margin) = 36px */ `; const SettingRight = styled.View` @@ -105,7 +108,7 @@ export default function SettingsScreen({ // Account settings state const [accountImage, setAccountImage] = useState( - "https://via.placeholder.com/100" + "https://via.placeholder.com/100", ); const [username, setUsername] = useState("john_doe"); const [email, setEmail] = useState("john.doe@example.com"); diff --git a/contexts/PlayerContext.tsx b/contexts/PlayerContext.tsx index ee58e4a..2278aa0 100644 --- a/contexts/PlayerContext.tsx +++ b/contexts/PlayerContext.tsx @@ -6,7 +6,7 @@ import React, { useRef, useEffect, } from "react"; -import TrackPlayer, { State, Event } from "react-native-track-player"; +import TrackPlayer, { State, Event } from "../utils/safeTrackPlayer"; import * as FileSystem from "expo-file-system"; import { AudioStreamManager, @@ -256,10 +256,10 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } }, [cacheProgress?.percentage, currentTrack?.id]); - // Update color theme only when track is ready (after loading completes) + // Update color theme immediately when track changes (before loading completes) useEffect(() => { const updateTheme = async () => { - if (!currentTrack?.thumbnail || isLoading) { + if (!currentTrack?.thumbnail) { setColorTheme({ primary: "#ffffff", secondary: "#ffffff", @@ -280,7 +280,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ }; updateTheme(); - }, [currentTrack?.thumbnail, isLoading]); + }, [currentTrack?.thumbnail]); // Monitor stream health and refresh if needed useEffect(() => { diff --git a/locales/fa.json b/locales/fa.json new file mode 100644 index 0000000..7e7a717 --- /dev/null +++ b/locales/fa.json @@ -0,0 +1,523 @@ +{ + "navigation": { + "search": "جستجو", + "library": "کتابخانه", + "settings": "تنظیمات", + "upcoming": "در صف پخش" + }, + "player": { + "now_playing": "در حال پخش", + "channel": "کانال", + "volume": "صدا", + "like": "پسندیدن", + "loop": "تکرار", + "more": "بیشتر", + "play_button": "پخش", + "play_previous": "قبلی", + "seek_backward": "عقب برو", + "seek_forward": "جلو برو", + "play_next": "بعدی", + "audiostreams_setup": "در حال راه‌اندازی جریان‌های صوتی…", + "audiostreams_insert": "در حال وارد کردن منبع صدا به پخش‌کننده…", + "livestreams_hls": "برای گوش دادن به پخش زنده، HLS را روشن کنید!", + "audiostreams_null": "جریان صوتی یافت نشد", + "lyrics": "متن ترانه", + "caching": "در حال ذخیره در حافظه موقت…", + "select_playlist": "انتخاب لیست پخش" + }, + "upcoming": { + "clear": "پاک کردن صف", + "shuffle": "تصادفی", + "remove": "حذف", + "filter_lt10": "فیلتر < ۱۰:۰۰", + "filter_ytm": "فیلتر YTM", + "enqueue_related": "افزودن جریان‌های مرتبط به صف", + "allow_duplicates": "مجوز تکرارها", + "info": "جریان‌های در صف اینجا نمایش داده می‌شوند", + "change": "تغییرات از جریان بعدی اعمال می‌شوند" + }, + "search": { + "placeholder": "جستجو یا وارد کردن آدرس YT [CTRL+K]", + "source_youtube": "یوتیوب", + "source_youtubemusic": "YouTube Music", + "source_soundcloud": "ساندکلاود", + "source_spotify": "اسپاتیفای", + "source_jiosaavn": "جیوساون", + "filter_all": "همه", + "filter_videos": "ویدئوها", + "filter_channels": "کانال‌ها", + "filter_playlists": "لیست‌های پخش", + "filter_music_songs": "آهنگ‌ها", + "filter_music_artists": "هنرمندان", + "filter_music_videos": "ویدئوها", + "filter_music_albums": "آلبوم‌ها", + "filter_music_playlists": "لیست‌های پخش", + "filter_sort_by": "مرتب‌سازی بر اساس", + "filter_date": "زمان", + "filter_views": "تعداد بازدید", + "filter_latest": "جدیدترین", + "filter_popular": "محبوب", + "live": "زنده", + "spotify_not_implemented": "جستجوی اسپاتیفای هنوز پیاده‌سازی نشده است" + }, + "loading": { + "text": "در حال بارگذاری..." + }, + "errors": { + "unknown_error": "خطای ناشناخته", + "no_image": "بدون تصویر" + }, + "library": { + "discover": "کشف کنید", + "history": "تاریخچه", + "favorites": "موردعلاقه‌ها", + "listen_later": "بعداً گوش دهید", + "featured": "برگزیده", + "collections": "مجموعه‌ها", + "playlists": "لیست‌های پخش", + "albums": "آلبوم‌ها", + "artists": "هنرمندان", + "channels": "کانال‌ها", + "feed": "خوراک اشتراک‌ها", + "for_you": "برای شما", + "import": "وارد کردن", + "export": "خروجی گرفتن", + "clean": "پاک کردن کتابخانه", + "clean_prompt": "آیا مطمئن هستید که می‌خواهید $ مورد را از کتابخانه پاک کنید؟", + "import_prompt": "این عمل، کتابخانه فعلی شما را با کتابخانه وارد شده ادغام می‌کند، ادامه دهید؟", + "imported": "کتابخانه با موفقیت وارد شد" + }, + "lists": { + "play": "پخش همه", + "enqueue": "افزودن همه به صف", + "import": "وارد کردن به عنوان مجموعه", + "imported": "$ با موفقیت به مجموعه‌های شما اضافه شد.", + "set_title": "تنظیم عنوان", + "clear_all": "پاک کردن همه", + "remove": "تغییر حالت حذف", + "delete": "حذف مجموعه", + "rename": "تغییر نام مجموعه", + "share": "اشتراک‌گذاری مجموعه", + "radio": "شروع رادیو", + "sort": "مرتب‌سازی دستی", + "sort_title": "مرتب‌سازی بر اساس عنوان A↔Z", + "sort_author": "مرتب‌سازی بر اساس نویسنده A↔Z", + "info": "موارد لیست پخش، کانال یا مجموعه شما اینجا نمایش داده می‌شوند", + "prompt_delete": "آیا مطمئن هستید که می‌خواهید مجموعه $ را حذف کنید؟", + "prompt_clear": "آیا مطمئن هستید که می‌خواهید $ را پاک کنید؟", + "prompt_rename": "عنوان جدید را وارد کنید" + }, + "actions": { + "menu_play_next": "پخش بعدی", + "menu_enqueue": "افزودن به صف", + "menu_start_radio": "شروع رادیو", + "menu_download": "دانلود", + "menu_watch_on": "تماشا در $", + "menu_view_artist": "مشاهده هنرمند", + "menu_view_lyrics": "مشاهده متن ترانه", + "menu_view_channel": "مشاهده کانال", + "menu_debug_info": "مشاهده جزئیات" + }, + "collection_selector": { + "add_to": "افزودن به مجموعه", + "create_new": "ایجاد مجموعه جدید", + "favorites": "موردعلاقه‌ها", + "listen_later": "بعداً گوش دهید" + }, + "settings": { + "custom_instance": "استفاده از نمونه سفارشی", + "enter_piped_api": "آدرس API پایپد را وارد کنید:", + "enter_invidious_api": "آدرس API اینویدیوس را وارد کنید:", + "language": "زبان", + "links_host": "میزبان پیوندها", + "download_format": "فرمت دانلود", + "pwa_share_action": "عمل اشتراک‌گذاری PWA", + "pwa_play": "پخش", + "pwa_watch": "تماشا", + "pwa_download": "دانلود", + "pwa_always_ask": "همیشه سؤال کن", + "search": "جستجو", + "set_songs_as_default_filter": "تنظیم آهنگ‌ها به عنوان فیلتر پیش‌فرض", + "display_suggestions": "نمایش پیشنهادات", + "playback": "پخش", + "audio_quality": "کیفیت صدا", + "prefetch": "پیش‌بارگذاری صف", + "codec_preference": "ترجیح کُدک", + "enforce_piped": "اجبار استفاده از پایپد برای پخش", + "always_proxy_streams": "همیشه جریان‌ها را از پروکسی عبور بده", + "stable_volume": "ترجیح حجم صوتی پایدار", + "hls": "پخش زنده HTTP", + "jiosaavn": "ترجیح جیوساون برای موسیقی", + "watchmode": "حالت تماشا", + "library": "کتابخانه", + "set_as_default_tab": "تنظیم به عنوان تب پیش‌فرض", + "library_sync": "همگام‌سازی ابری", + "store_discoveries": "ذخیره کشفیات", + "clear_discoveries": "این عمل $ کشف موجود شما را پاک می‌کند، ادامه دهید؟", + "store_history": "ذخیره تاریخچه", + "clear_history": "این عمل $ مورد را از تاریخچه شما پاک می‌کند، ادامه دهید؟", + "import_from_piped": "وارد کردن لیست‌های پخش از پایپد", + "interface": "رابط کاربری", + "load_images": "بارگذاری تصاویر", + "roundness": "گردی گوشه‌ها", + "roundness_none": "هیچ", + "roundness_lighter": "ملایم‌تر", + "roundness_light": "ملایم", + "roundness_heavy": "شدید", + "roundness_heavier": "شدیدتر", + "use_custom_color": "استفاده از رنگ سفارشی", + "custom_color_prompt": "مقدار rgb را به فرمت r,g,b وارد کنید", + "theming_scheme": "طرح رنگ‌بندی", + "theming_scheme_dynamic": "پویا", + "theming_scheme_system": "سیستم", + "theming_scheme_light": "روشن", + "theming_scheme_dark": "تیره", + "theming_scheme_hc": "کنتراست بالا", + "theming_scheme_hc_system": "سیستم", + "theming_scheme_white": "سفید", + "theming_scheme_black": "سیاه", + "fullscreen": "تغییر حالت تمام‌صفحه", + "parental_controls": "کنترل والدین", + "pin_toggle": "تنظیم", + "pin_message": "برای تنظیم کنترل والدین، PIN مورد نیاز است. پس از آن، برنامه برای ادغام مدیر بخش‌ها، دوباره بارگذاری می‌شود.", + "pin_prompt": "PIN را وارد کنید", + "pin_incorrect": "PIN نادرست است!", + "feedback_placeholder": "نظرات خود (باگ‌ها، درخواست‌های قابلیت) را به صورت ناشناس اینجا وارد کنید:", + "feedback_submit": "ارسال نظر", + "changelog": "تاریخچه تغییرات", + "clear_cache": "پاک کردن حافظه موقت", + "restore": "بازیابی تنظیمات", + "export": "خروجی گرفتن از تنظیمات", + "import": "وارد کردن تنظیمات", + "reload": "بارگذاری مجدد صفحه برای اعمال تغییرات" + }, + "piped": { + "enter_auth": "آدرس API نمونه احراز هویت پایپد را وارد کنید:", + "enter_username": "نام کاربری را وارد کنید:", + "enter_password": "گذرواژه", + "success_auth": "با موفقیت از حساب پایپد خود خارج شدید.", + "failed_auth": "خروج با موفقیت انجام نشد", + "success_imported": "لیست‌های پخش از حساب پایپد شما با موفقیت به عنوان مجموعه به ytify وارد شدند", + "failed_imported": "وارد کردن همه لیست‌های پخش با موفقیت انجام نشد، خطا: $", + "success_fetched": "لیست‌های پخش از حساب با موفقیت بازیابی شدند.", + "failed_find": "یافتن لیست‌های پخش ناموفق بود، خطا: $", + "failed_login": "ورود ناموفق بود، خطا: $", + "failed_token": "توکن احراز هویت یافت نشد! فرآیند ورود لغو شد.", + "success_logged": "با موفقیت به حساب وارد شدید." + }, + "updater": { + "changelog_full": "خواندن همه تغییرات قبلی", + "update": "بروزرسانی", + "later": "بعداً" + }, + "pwa": { + "share_prompt": "برای پخش، تأیید را بزنید. برای دانلود، انصراف را بزنید." + }, + "screens": { + "album_playlist": { + "title": "آلبوم", + "placeholder_playlist": "نام لیست پخش", + "add_to_library": "افزودن به کتابخانه", + "remove_from_library": "حذف از کتابخانه" + }, + "artist": { + "title": "هنرمند", + "songs": "آهنگ‌ها", + "albums": "آلبوم‌ها", + "videos": "ویدئوها", + "playlists": "لیست‌های پخش", + "follow": "دنبال کردن", + "following": "در حال دنبال کردن", + "monthly_listeners": "شنونده ماهانه", + "plays": "بار پخش", + "retry": "تلاش مجدد", + "go_back": "بازگشت", + "artist_not_found": "هنرمند یافت نشد", + "unknown_artist": "هنرمند ناشناس", + "unknown_title": "عنوان ناشناس", + "unknown_album": "آلبوم ناشناس", + "unknown_playlist": "لیست پخش ناشناس", + "recent_videos": "ویدئوهای اخیر", + "popular_videos": "ویدئوهای محبوب", + "official_audio": "صوت رسمی" + }, + "library": { + "title": "کتابخانه شما", + "recents": "موارد اخیر", + "playlist": "لیست پخش", + "sections": { + "playlists": "لیست‌های پخش", + "albums": "آلبوم‌ها", + "artists": "هنرمندان", + "downloaded": "دانلود شده‌ها" + } + }, + "liked_songs": { + "title": "آهنگ‌های موردعلاقه" + }, + "previously_played": { + "title": "پخش‌های قبلی" + }, + "search": { + "artists": "هنرمندان", + "albums": "آلبوم‌ها", + "all": "همه", + "videos": "ویدئوها", + "channels": "کانال‌ها", + "playlists": "لیست‌های پخش", + "latest": "جدیدترین", + "popular": "محبوب", + "tracks": "قطعات", + "songs": "آهنگ‌ها", + "youtube": "یوتیوب", + "youtube_music": "YouTube Music", + "soundcloud": "ساندکلاود", + "jiosaavn": "جیوساون", + "spotify": "اسپاتیفای" + }, + "settings": { + "main": { + "title": "تنظیمات", + "account_title": "حساب کاربری", + "playback_title": "پخش", + "privacy_title": "حریم خصوصی و اجتماعی", + "notifications_title": "اعلان‌ها", + "data_title": "صرفه‌جویی داده و آفلاین", + "quality_title": "کیفیت رسانه", + "support_title": "درباره و پشتیبانی", + "sections": { + "account": { + "title": "حساب کاربری", + "subtitle": "پروفایل و ترجیحات خود را مدیریت کنید" + }, + "playback": { + "title": "پخش", + "subtitle": "کیفیت صدا و تنظیمات پخش" + }, + "privacy": { + "title": "حریم خصوصی و اجتماعی", + "subtitle": "تنظیمات حریم خصوصی و ویژگی‌های اجتماعی" + }, + "notifications": { + "title": "اعلان‌ها", + "subtitle": "اعلان‌های فشاری و هشدارها" + }, + "data": { + "title": "صرفه‌جویی داده و آفلاین", + "subtitle": "تنظیمات دانلود و مصرف داده" + }, + "quality": { + "title": "کیفیت رسانه", + "subtitle": "ترجیحات کیفیت صدا" + }, + "support": { + "help_center": "مرکز راهنما", + "help_center_desc": "پاسخ سوالات متداول را پیدا کنید", + "community": "انجمن", + "community_desc": "با سایر کاربران ارتباط برقرار کنید", + "app_version": "نسخه برنامه", + "app_version_desc": "نسخه ۱.۰.۰", + "terms_of_service": "شرایط خدمات", + "terms_of_service_desc": "شرایط و ضوابط ما را بخوانید", + "privacy_policy": "خط‌مشی حریم خصوصی", + "privacy_policy_desc": "بیاموزید که چگونه از داده‌های شما محافظت می‌کنیم" + } + }, + "library": { + "sections": { + "playlists": "لیست‌های پخش", + "albums": "آلبوم‌ها", + "artists": "هنرمندان", + "downloaded": "دانلود شده‌ها" + }, + "liked_songs": "آهنگ‌های موردعلاقه", + "previously_played": "پخش‌های قبلی", + "playlist_meta": "لیست پخش • {count} آهنگ", + "enter_playlist_name": "نام لیست پخش را وارد کنید", + "please_enter_name": "لطفاً یک نام برای لیست پخش وارد کنید", + "failed_create_playlist": "ایجاد لیست پخش ناموفق بود", + "loading_album": "در حال بارگذاری آلبوم...", + "your_library": "کتابخانه شما", + "recents": "موارد اخیر", + "playlist": "لیست پخش", + "album": "آلبوم", + "refresh_error": "تلاش برای بارگذاری مجدد یا بررسی اتصال اینترنت خود", + "no_songs_found": "هیچ آهنگی یافت نشد", + "album_empty": "این آلبوم در حال حاضر خالی است.", + "share": "اشتراک‌گذاری", + "add_to_other_playlist": "افزودن به لیست پخش دیگر", + "go_to_album": "برو به آلبوم", + "go_to_artists": "برو به هنرمندان", + "sleep_timer": "تایمر خواب", + "go_to_song_radio": "برو به رادیوی آهنگ", + "view_song_credits": "مشاهده اعتبارات آهنگ", + "lyrics_service_unavailable": "سرویس متن ترانه موقتاً در دسترس نیست", + "couldnt_load_lyrics": "بارگذاری متن ترانه برای این قطعه ممکن نشد", + "cached_100": "در حافظه موقت: ۱۰۰٪", + "top_hits": "برترین‌ها", + "new_releases": "آثار جدید", + "items": "مورد", + "curated": "گردآوری شده", + "weekly": "هفتگی", + "youtube": "یوتیوب", + "youtube_music": "YouTube Music", + "soundcloud": "ساندکلاود", + "spotify": "اسپاتیفای", + "jiosaavn": "جیوساون", + "all": "همه", + "videos": "ویدئوها", + "channels": "کانال‌ها", + "playlists": "لیست‌های پخش", + "latest": "جدیدترین", + "popular": "محبوب", + "artists": "هنرمندان", + "tracks": "قطعات", + "songs": "آهنگ‌ها", + "albums": "آلبوم‌ها" + } + }, + "album_playlist": { + "loading_album": "در حال بارگذاری آلبوم...", + "try_refreshing": "تلاش برای بارگذاری مجدد یا بررسی اتصال اینترنت خود", + "album": "آلبوم", + "no_songs_found": "هیچ آهنگی یافت نشد", + "album_empty": "این آلبوم خالی است", + "account": { + "title": "حساب کاربری", + "account_details": "جزئیات حساب", + "account_image": "تصویر حساب", + "username": "نام کاربری", + "username_placeholder": "your_username", + "email": "ایمیل", + "email_placeholder": "user@example.com" + }, + "playback": { + "title": "پخش", + "gapless_playback": "پخش بی‌فاصله", + "gapless_playback_desc": "پخش قطعات بدون وقفه", + "automix": "میکس خودکار", + "automix_desc": "میکس خودکار بین آهنگ‌ها", + "crossfade": "محو متقاطع", + "crossfade_desc": "انتقال نرم بین قطعات ({time}s)", + "listening_controls": "کنترل‌های شنیدن", + "autoplay": "پخش خودکار", + "autoplay_desc": "ادامه پخش موسیقی مشابه", + "mono_audio": "صوت مونو", + "mono_audio_desc": "ترکیب کانال‌های صوتی", + "equalizer": "اکوالایزر", + "volume_normalization": "یکسان‌سازی حجم صدا", + "volume_normalization_desc": "حجم صوتی یکسان در بین قطعات" + }, + "privacy": { + "title": "حریم خصوصی و اجتماعی", + "private_session": "جلسه خصوصی", + "private_session_desc": "شروع یک جلسه شنیدن خصوصی", + "listening_activity": "فعالیت شنیداری", + "listening_activity_desc": "اشتراک‌گذاری آنچه گوش می‌دهم با دنبال‌کنندگان", + "recently_played_artists": "هنرمندان اخیراً پخش شده", + "recently_played_artists_desc": "نمایش هنرمندان اخیراً پخش شده در پروفایل", + "public_profile": "عمومی کردن پروفایل من", + "public_profile_desc": "اجازه به دیگران برای یافتن و دنبال کردن شما" + }, + "notifications": { + "title": "اعلان‌ها", + "push_notifications": "اعلان‌های فشاری", + "push_notifications_desc": "دریافت اعلان‌ها در دستگاه شما", + "email_notifications": "اعلان‌های ایمیلی", + "email_notifications_desc": "دریافت به‌روزرسانی‌ها از طریق ایمیل", + "new_music_alerts": "هشدارهای موسیقی جدید", + "new_music_alerts_desc": "اعلان‌ها برای آثار جدید", + "playlist_updates": "به‌روزرسانی‌های لیست پخش", + "playlist_updates_desc": "وقتی لیست‌های پخشی که دنبال می‌کنید به‌روز می‌شوند" + }, + "data": { + "title": "صرفه‌جویی داده و آفلاین", + "data_saver": "صرفه‌جویی داده", + "data_saver_desc": "کاهش مصرف داده در هنگام پخش جریانی", + "download_quality": "کیفیت دانلود", + "download_quality_desc": "کیفیت معمولی برای دانلود‌ها", + "auto_download_playlists": "دانلود خودکار لیست‌های پخش", + "auto_download_playlists_desc": "لیست‌های پخش شما را به صورت خودکار دانلود کن", + "offline_mode": "حالت آفلاین", + "offline_mode_desc": "فقط محتوای دانلود شده را پخش کن" + }, + "quality": { + "title": "کیفیت رسانه", + "streaming_quality": "کیفیت پخش جریانی", + "streaming_quality_desc": "کیفیت بالا برای پخش جریانی", + "download_quality": "کیفیت دانلود", + "download_quality_desc": "کیفیت بالا برای دانلود‌ها", + "normalize_volume": "یکسان‌سازی حجم صدا", + "normalize_volume_desc": "حجم صوتی یکسان در بین قطعات", + "equalizer": "اکوالایزر", + "equalizer_desc": "تنظیمات صوتی سفارشی" + }, + "support": { + "title": "درباره و پشتیبانی", + "help_center": "مرکز راهنما", + "help_center_desc": "پاسخ سوالات متداول را پیدا کنید", + "community": "انجمن", + "community_desc": "با سایر کاربران ارتباط برقرار کنید", + "app_version": "نسخه برنامه", + "app_version_desc": "نسخه ۱.۰.۰", + "terms_of_service": "شرایط خدمات", + "terms_of_service_desc": "شرایط و ضوابط ما را بخوانید", + "privacy_policy": "خط‌مشی حریم خصوصی", + "privacy_policy_desc": "بیاموزید که چگونه از داده‌های شما محافظت می‌کنیم" + } + } + }, + "loading": { + "loading": "در حال بارگذاری...", + "loading_album": "در حال بارگذاری آلبوم...", + "loading_lyrics": "در حال بارگذاری متن ترانه..." + }, + "errors": { + "fetchlist_url_null": "آدرس کانال ارائه نشده است", + "fetchlist_error": "دریافت داده‌های لیست پخش ممکن نشد", + "fetchlist_nonexistent": "خطا دریافت شد: \"لیست پخش وجود ندارد.\"", + "invalid_artist_data": "داده هنرمند نامعتبر دریافت شد", + "failed_get_base64": "دریافت داده base64 از تصویر ناموفق بود", + "no_image_data": "داده تصویری در نتیجه رمزگشایی شده یافت نشد", + "http_error": "HTTP {status}", + "invalid_response": "فرمت پاسخ نامعتبر است", + "no_songs_found": "پس از چندین تلاش، هیچ آهنگی برای این آلبوم یافت نشد" + }, + "placeholders": { + "no_image": "بدون تصویر", + "music": "موسیقی", + "artist": "هنرمند", + "album": "آلبوم", + "unknown": "ناشناس", + "unknown_title": "عنوان ناشناس", + "unknown_artist": "هنرمند ناشناس", + "unknown_album": "آلبوم ناشناس" + }, + "actions": { + "download": "دانلود", + "share": "اشتراک‌گذاری", + "add_to_other_playlist": "افزودن به لیست پخش دیگر", + "go_to_album": "برو به آلبوم", + "go_to_artists": "برو به هنرمندان", + "sleep_timer": "تایمر خواب", + "remove_song_from_playlist": "حذف آهنگ از لیست پخش", + "go_to_song_radio": "برو به رادیوی آهنگ", + "rename_playlist": "تغییر نام لیست پخش", + "remove_playlist": "حذف لیست پخش", + "cancel": "انصراف", + "save": "ذخیره", + "select_playlist": "انتخاب لیست پخش", + "no_playlists_found": "هیچ لیست پخشی یافت نشد", + "create_playlist_first": "ابتدا یک لیست پخش ایجاد کنید تا آهنگ‌ها را اضافه کنید", + "playlist_name": "نام لیست پخش", + "now_playing": "در حال پخش", + "no_track_loaded": "هیچ قطعه‌ای بارگذاری نشده است", + "no_track_selected": "هیچ قطعه‌ای انتخاب نشده است", + "select_track_to_start": "برای شروع پخش، یک قطعه انتخاب کنید" + }, + "streamed": "پخش جریانی شد", + "utils": { + "live": "زنده" + } + } +} \ No newline at end of file diff --git a/modules/audioStreaming.ts b/modules/audioStreaming.ts index 6cbb560..64ab23c 100644 --- a/modules/audioStreaming.ts +++ b/modules/audioStreaming.ts @@ -1,6 +1,7 @@ import { Audio } from "expo-av"; import * as FileSystem from "expo-file-system/legacy"; import { toByteArray, fromByteArray } from "base64-js"; +import { API, fetchWithRetry } from "../components/core/api"; // Cache directory configuration const CACHE_CONFIG = { @@ -130,7 +131,7 @@ export class AudioStreamManager { private updateDownloadProgress( trackId: string, downloadedMB: number, - speed: number, + speed: number ): void { const progress = this.cacheProgress.get(trackId); if (progress) { @@ -146,7 +147,7 @@ export class AudioStreamManager { estimatedTotalSize: progress.estimatedTotalSize, isFullyCached: progress.isFullyCached, originalStreamUrl: progress.originalStreamUrl, - }, + } ); } } @@ -173,11 +174,11 @@ export class AudioStreamManager { if (this.cacheDirectory) { console.log( - `[Audio] Successfully initialized cache directory: ${this.cacheDirectory}`, + `[Audio] Successfully initialized cache directory: ${this.cacheDirectory}` ); } else { console.warn( - "[Audio] No writable cache directory available, caching will be disabled", + "[Audio] No writable cache directory available, caching will be disabled" ); } } @@ -206,7 +207,7 @@ export class AudioStreamManager { estimatedTotalSize?: number; isFullyCached?: boolean; originalStreamUrl?: string; - }, + } ): boolean { const now = Date.now(); const existingProgress = this.cacheProgress.get(trackId); @@ -222,7 +223,7 @@ export class AudioStreamManager { if (isSignificantRegression && !isFileSizeUpdate) { console.warn( - `[CacheProgress] Preventing regression for ${trackId}: ${existingProgress.percentage}% -> ${newPercentage}%`, + `[CacheProgress] Preventing regression for ${trackId}: ${existingProgress.percentage}% -> ${newPercentage}%` ); return false; } @@ -238,7 +239,7 @@ export class AudioStreamManager { !options?.isFullyCached // Always allow completion updates ) { console.log( - `[CacheProgress] Skipping minor update for ${trackId}: ${progressDelta}% in ${timeSinceLastUpdate}ms`, + `[CacheProgress] Skipping minor update for ${trackId}: ${progressDelta}% in ${timeSinceLastUpdate}ms` ); return false; } @@ -267,7 +268,7 @@ export class AudioStreamManager { this.cacheProgress.set(trackId, updatedProgress); console.log( - `[CacheProgress] Updated progress for ${trackId}: ${newPercentage}%${fileSize ? ` (${Math.round(fileSize * 100) / 100}MB)` : ""}${options?.downloadedSize ? ` downloaded: ${Math.round(options.downloadedSize * 100) / 100}MB` : ""}`, + `[CacheProgress] Updated progress for ${trackId}: ${newPercentage}%${fileSize ? ` (${Math.round(fileSize * 100) / 100}MB)` : ""}${options?.downloadedSize ? ` downloaded: ${Math.round(options.downloadedSize * 100) / 100}MB` : ""}` ); // Clear cache info cache since progress changed @@ -300,7 +301,7 @@ export class AudioStreamManager { this.cacheProgress.set(trackId, updatedProgress); console.log( - `[CacheProgress] Download started for ${trackId}${streamUrl ? ` from: ${streamUrl.substring(0, 50)}...` : ""}`, + `[CacheProgress] Download started for ${trackId}${streamUrl ? ` from: ${streamUrl.substring(0, 50)}...` : ""}` ); } @@ -330,7 +331,7 @@ export class AudioStreamManager { // Clear cache info cache since the file status changed this.clearCacheInfoCache(trackId); console.log( - `[CacheProgress] Download completed for ${trackId}: ${Math.round(fileSize * 100) / 100}MB (took ${existingProgress ? Math.round((now - existingProgress.downloadStartTime) / 1000) : 0}s)`, + `[CacheProgress] Download completed for ${trackId}: ${Math.round(fileSize * 100) / 100}MB (took ${existingProgress ? Math.round((now - existingProgress.downloadStartTime) / 1000) : 0}s)` ); } @@ -364,12 +365,12 @@ export class AudioStreamManager { downloadStartTime: Date.now(), }); console.log( - `[CacheProgress] Preserved URL in stale cleanup for ${trackId}`, + `[CacheProgress] Preserved URL in stale cleanup for ${trackId}` ); } else { this.cacheProgress.delete(trackId); console.log( - `[CacheProgress] Cleaned up stale progress for ${trackId}`, + `[CacheProgress] Cleaned up stale progress for ${trackId}` ); } } @@ -380,11 +381,11 @@ export class AudioStreamManager { // Convert video stream to audio format by finding audio-only alternatives private async convertStreamToMP3( videoUrl: string, - videoId: string, + videoId: string ): Promise { try { console.log( - `[AudioStreamManager] Converting video stream to audio for video: ${videoId}`, + `[AudioStreamManager] Converting video stream to audio for video: ${videoId}` ); // Method 1: Try to find audio-only streams with specific itags @@ -415,14 +416,14 @@ export class AudioStreamManager { if (testResponse.ok) { console.log( - `[AudioStreamManager] Found working audio-only stream with itag ${itag}`, + `[AudioStreamManager] Found working audio-only stream with itag ${itag}` ); return audioOnlyUrl; } } catch (error) { console.warn( `[AudioStreamManager] Audio-only itag ${itag} failed:`, - error, + error ); continue; } @@ -431,7 +432,7 @@ export class AudioStreamManager { // Method 2: Try to modify the URL to get an audio-only version // Remove video-specific parameters and add audio-specific ones console.log( - "[AudioStreamManager] Trying URL modification for audio extraction", + "[AudioStreamManager] Trying URL modification for audio extraction" ); try { @@ -478,7 +479,7 @@ export class AudioStreamManager { // Method 3: Last resort - return the original URL with audio extraction hint // The player will need to handle video streams that contain audio console.warn( - "[AudioStreamManager] All audio extraction methods failed, returning original stream URL with audio hint", + "[AudioStreamManager] All audio extraction methods failed, returning original stream URL with audio hint" ); // Add a query parameter to indicate this is an audio extraction request @@ -487,7 +488,7 @@ export class AudioStreamManager { // Log for debugging console.log( - "[AudioStreamManager] Returning URL with audio extraction hint", + "[AudioStreamManager] Returning URL with audio extraction hint" ); return audioExtractionUrl; @@ -497,7 +498,7 @@ export class AudioStreamManager { // Even in case of error, return the original URL so playback can still work // The player might be able to handle the video stream directly console.warn( - `[AudioStreamManager] Returning original URL due to extraction error: ${videoUrl}`, + `[AudioStreamManager] Returning original URL due to extraction error: ${videoUrl}` ); return videoUrl; } @@ -565,7 +566,7 @@ export class AudioStreamManager { : `file://${genericCachedPath}`; } else { console.log( - `[Audio] Generic cached file doesn't exist, removing from cache: ${genericCachedPath}`, + `[Audio] Generic cached file doesn't exist, removing from cache: ${genericCachedPath}` ); this.trackCache.delete(trackId); } @@ -587,7 +588,7 @@ export class AudioStreamManager { const cachedFileInfo = await FileSystem.getInfoAsync(cachedPath); if (!cachedFileInfo.exists) { console.log( - `[Audio] Cached file doesn't exist, removing from cache: ${cachedPath}`, + `[Audio] Cached file doesn't exist, removing from cache: ${cachedPath}` ); this.soundCloudCache.delete(trackId); // Continue to filesystem scan below @@ -619,7 +620,7 @@ export class AudioStreamManager { : `file://${fullCachedPath}`; } else { console.log( - `[Audio] Full cached file doesn't exist, removing from cache: ${fullCachedPath}`, + `[Audio] Full cached file doesn't exist, removing from cache: ${fullCachedPath}` ); this.trackCache.delete(trackId + "_full"); this.trackCache.delete(trackId); @@ -628,7 +629,7 @@ export class AudioStreamManager { // If not in memory, scan filesystem for existing cache files console.log( - `[Audio] Scanning filesystem for cache files for track: ${trackId}`, + `[Audio] Scanning filesystem for cache files for track: ${trackId}` ); // Get the best available cache directory @@ -660,7 +661,7 @@ export class AudioStreamManager { const isValid = await this.validateCachedFile(filePath); if (isValid) { console.log( - `[Audio] Found existing SoundCloud cache file: ${filePath}`, + `[Audio] Found existing SoundCloud cache file: ${filePath}` ); this.soundCloudCache.set(trackId + "_full", filePath); this.soundCloudCache.set(trackId, filePath); @@ -671,7 +672,7 @@ export class AudioStreamManager { : `file://${filePath}`; } else { console.warn( - `[Audio] Found corrupted SoundCloud cache file, cleaning up: ${filePath}`, + `[Audio] Found corrupted SoundCloud cache file, cleaning up: ${filePath}` ); await FileSystem.deleteAsync(filePath, { idempotent: true }); } @@ -685,7 +686,7 @@ export class AudioStreamManager { const youtubeCacheDir = await this.getCacheDirectory(); if (!youtubeCacheDir) { console.warn( - "[Audio] No cache directory available, skipping filesystem scan", + "[Audio] No cache directory available, skipping filesystem scan" ); return null; } @@ -709,7 +710,7 @@ export class AudioStreamManager { const isValid = await this.validateCachedFile(filePath); if (isValid) { console.log( - `[Audio] Found existing YouTube cache file: ${filePath}`, + `[Audio] Found existing YouTube cache file: ${filePath}` ); // Mark as full if it has .full extension or is substantial @@ -726,7 +727,7 @@ export class AudioStreamManager { : `file://${filePath}`; } else { console.warn( - `[Audio] Found corrupted YouTube cache file, cleaning up: ${filePath}`, + `[Audio] Found corrupted YouTube cache file, cleaning up: ${filePath}` ); await FileSystem.deleteAsync(filePath, { idempotent: true }); } @@ -746,7 +747,7 @@ export class AudioStreamManager { private async tryAlternativeClientIds( baseUrl: string, trackData: any, - controller: AbortController, + controller: AbortController ): Promise { const originalIndex = this.currentClientIdIndex; @@ -795,7 +796,7 @@ export class AudioStreamManager { }> { try { console.log( - `[Audio] Validating audio stream: ${url.substring(0, 100)}...`, + `[Audio] Validating audio stream: ${url.substring(0, 100)}...` ); const controller = new AbortController(); @@ -822,7 +823,7 @@ export class AudioStreamManager { const contentType = response.headers.get("content-type") || ""; const contentLength = parseInt( - response.headers.get("content-length") || "0", + response.headers.get("content-length") || "0" ); // Check if content type is supported by Expo AV @@ -841,7 +842,7 @@ export class AudioStreamManager { const isSupportedType = supportedTypes.some( (type) => contentType.toLowerCase().includes(type) || - url.toLowerCase().includes(type.replace("audio/", "")), + url.toLowerCase().includes(type.replace("audio/", "")) ); if (!isSupportedType && contentType && !contentType.includes("audio")) { @@ -865,7 +866,7 @@ export class AudioStreamManager { } console.log( - `[Audio] Stream validation successful: ${contentType}, ${contentLength} bytes`, + `[Audio] Stream validation successful: ${contentType}, ${contentLength} bytes` ); return { isValid: true, @@ -891,7 +892,7 @@ export class AudioStreamManager { if (!fileInfo.exists || !fileInfo.size || fileInfo.size === 0) { console.warn( - "[Audio] File validation failed: file doesn't exist or is empty", + "[Audio] File validation failed: file doesn't exist or is empty" ); return false; } @@ -899,7 +900,7 @@ export class AudioStreamManager { // Check minimum file size (10KB for meaningful audio data) if (fileInfo.size < 10240) { console.warn( - `[Audio] File validation failed: file too small (${fileInfo.size} bytes)`, + `[Audio] File validation failed: file too small (${fileInfo.size} bytes)` ); return false; } @@ -913,7 +914,7 @@ export class AudioStreamManager { if (!testRead || testRead.length === 0) { console.warn( - "[Audio] File validation failed: cannot read file content", + "[Audio] File validation failed: cannot read file content" ); return false; } @@ -962,7 +963,7 @@ export class AudioStreamManager { } catch (cleanupError) { console.warn( `[Audio] Failed to clean up file ${filePath}:`, - cleanupError, + cleanupError ); } } @@ -985,7 +986,7 @@ export class AudioStreamManager { downloadStartTime: Date.now(), }); console.log( - `[Audio] Preserved original URL for track: ${trackId} during cleanup`, + `[Audio] Preserved original URL for track: ${trackId} during cleanup` ); } else { // Clear cache progress for this track if no URL to preserve @@ -993,12 +994,12 @@ export class AudioStreamManager { } console.log( - `[Audio] Partial cache cleanup completed for track: ${trackId}`, + `[Audio] Partial cache cleanup completed for track: ${trackId}` ); } catch (error) { console.warn( `[Audio] Error during partial cache cleanup for ${trackId}:`, - error, + error ); } } @@ -1034,7 +1035,7 @@ export class AudioStreamManager { } console.log( - `[Audio] Estimated total size: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB for current size: ${Math.round((fileSize / 1024 / 1024) * 100) / 100}MB`, + `[Audio] Estimated total size: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB for current size: ${Math.round((fileSize / 1024 / 1024) * 100) / 100}MB` ); return estimatedTotalSize; @@ -1057,7 +1058,7 @@ export class AudioStreamManager { const cached = this.cacheInfoCache.get(trackId); if (cached && Date.now() - cached.timestamp < this.CACHE_INFO_TTL) { console.log( - `[Audio] Using cached cache info for ${trackId} (age: ${Date.now() - cached.timestamp}ms)`, + `[Audio] Using cached cache info for ${trackId} (age: ${Date.now() - cached.timestamp}ms)` ); return cached.result; } @@ -1075,7 +1076,7 @@ export class AudioStreamManager { activeProgress.isFullyCached ) { console.log( - `[Audio] Track ${trackId} is fully cached (100% confirmed)`, + `[Audio] Track ${trackId} is fully cached (100% confirmed)` ); const result = { percentage: 100, @@ -1088,7 +1089,7 @@ export class AudioStreamManager { }; console.log( `[Audio] === getCacheInfo END (100% cached) for ${trackId} ===`, - result, + result ); return result; } @@ -1098,7 +1099,7 @@ export class AudioStreamManager { // Ensure percentage doesn't decrease during active download const safePercentage = Math.max( activeProgress.percentage, - activeProgress.lastFileSize > 0 ? 1 : 0, + activeProgress.lastFileSize > 0 ? 1 : 0 ); const result = { percentage: safePercentage, @@ -1111,7 +1112,7 @@ export class AudioStreamManager { }; console.log( `[Audio] === getCacheInfo END (downloading) for ${trackId} ===`, - result, + result ); return result; } @@ -1129,7 +1130,7 @@ export class AudioStreamManager { }; console.log( `[Audio] === getCacheInfo END (stored progress) for ${trackId} ===`, - result, + result ); return result; } @@ -1144,7 +1145,7 @@ export class AudioStreamManager { const result = { percentage: 0, fileSize: 0, isFullyCached: false }; console.log( `[Audio] === getCacheInfo END (no file) for ${trackId} ===`, - result, + result ); return result; } @@ -1158,7 +1159,7 @@ export class AudioStreamManager { // If file doesn't exist, try with the full path including file:// if (!fileInfo || !fileInfo.exists) { console.log( - `[Audio] Cached file not found at: ${filePath}, trying with file:// prefix`, + `[Audio] Cached file not found at: ${filePath}, trying with file:// prefix` ); fileInfo = await FileSystem.getInfoAsync(cachedFilePath); // console.log("[Audio] File info (with file://):", fileInfo); @@ -1166,12 +1167,12 @@ export class AudioStreamManager { if (!fileInfo || !fileInfo.exists) { console.log( - `[Audio] Cached file not found: ${filePath} or ${cachedFilePath}`, + `[Audio] Cached file not found: ${filePath} or ${cachedFilePath}` ); const result = { percentage: 0, fileSize: 0, isFullyCached: false }; console.log( `[Audio] === getCacheInfo END (file missing) for ${trackId} ===`, - result, + result ); return result; } @@ -1179,19 +1180,19 @@ export class AudioStreamManager { // Check if it's fully cached or has substantial cache const isFullyCached = this.hasFullCachedFile(trackId); const hasSubstantialCache = this.soundCloudCache.has( - trackId + "_substantial", + trackId + "_substantial" ); const fileSize = fileInfo.size || 0; console.log( - `[Audio] Cache status for ${trackId}: fullyCached=${isFullyCached}, substantial=${hasSubstantialCache}, size=${fileSize} bytes`, + `[Audio] Cache status for ${trackId}: fullyCached=${isFullyCached}, substantial=${hasSubstantialCache}, size=${fileSize} bytes` ); // For very small files (< 10KB), consider them as not meaningfully cached const minFileSize = 10240; // 10KB minimum if (fileSize < minFileSize) { console.log( - `[Audio] File too small to be considered cached: ${fileSize} bytes (min: ${minFileSize})`, + `[Audio] File too small to be considered cached: ${fileSize} bytes (min: ${minFileSize})` ); const result = { percentage: 0, @@ -1200,7 +1201,7 @@ export class AudioStreamManager { }; console.log( `[Audio] === getCacheInfo END (too small) for ${trackId} ===`, - result, + result ); return result; } @@ -1223,7 +1224,7 @@ export class AudioStreamManager { // Use stored estimate if available and larger than current file estimatedTotalSize = storedEstimatedSize; console.log( - `[Audio] Using stored estimated size: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB`, + `[Audio] Using stored estimated size: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB` ); } else { // Dynamic estimation based on file size patterns @@ -1232,37 +1233,37 @@ export class AudioStreamManager { // 10MB+ - likely complete or near-complete, but cap at 12MB estimatedTotalSize = Math.min(fileSize * 1.2, 12582912); // 20% buffer, max 12MB console.log( - `[Audio] Large file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (20% buffer)`, + `[Audio] Large file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (20% buffer)` ); } else if (fileSize >= 7340032) { // 7-10MB - estimate 10-12MB total with buffer estimatedTotalSize = Math.max(10485760, fileSize * 1.3); // Min 10MB, 30% buffer console.log( - `[Audio] Medium-large file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (30% buffer)`, + `[Audio] Medium-large file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (30% buffer)` ); } else if (fileSize >= 5242880) { // 5-7MB - estimate 8-10MB total with buffer estimatedTotalSize = Math.max(8388608, fileSize * 1.4); // Min 8MB, 40% buffer console.log( - `[Audio] Medium file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (40% buffer)`, + `[Audio] Medium file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (40% buffer)` ); } else if (fileSize >= 3145728) { // 3-5MB - estimate 6-8MB total with buffer (this is our current case) estimatedTotalSize = Math.max(6291456, fileSize * 1.8); // Min 6MB, 80% buffer console.log( - `[Audio] Small-medium file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (80% buffer)`, + `[Audio] Small-medium file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (80% buffer)` ); } else if (fileSize >= 2097152) { // 2-3MB - estimate 4-6MB total with buffer estimatedTotalSize = Math.max(4194304, fileSize * 2.0); // Min 4MB, 100% buffer console.log( - `[Audio] Small file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (100% buffer)`, + `[Audio] Small file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (100% buffer)` ); } else { // Less than 2MB - use conservative 4MB estimate estimatedTotalSize = 4194304; // 4MB console.log( - `[Audio] Very small file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (fixed)`, + `[Audio] Very small file estimation: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB (fixed)` ); } } @@ -1277,7 +1278,7 @@ export class AudioStreamManager { // Never allow percentage to decrease significantly (more than 5%) if (stablePercentage < existingPercentage - 5) { console.log( - `[Audio] Preventing percentage drop: ${existingPercentage}% -> ${stablePercentage}%`, + `[Audio] Preventing percentage drop: ${existingPercentage}% -> ${stablePercentage}%` ); stablePercentage = Math.max(stablePercentage, existingPercentage - 2); // Allow max 2% drop } @@ -1286,12 +1287,12 @@ export class AudioStreamManager { if (stablePercentage > 85 && fileSize > 0) { const newEstimatedTotal = Math.max( estimatedTotalSize, - fileSize * 1.1, + fileSize * 1.1 ); if (newEstimatedTotal > estimatedTotalSize) { estimatedTotalSize = newEstimatedTotal; console.log( - `[Audio] Boosting estimated total to prevent premature 100%: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB`, + `[Audio] Boosting estimated total to prevent premature 100%: ${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB` ); // Recalculate percentage with new estimate const newRawPercentage = (fileSize / estimatedTotalSize) * 100; @@ -1305,7 +1306,7 @@ export class AudioStreamManager { if (hasSubstantialCache && percentage < 90) { percentage = Math.min(95, percentage + 5); console.log( - `[Audio] Boosting cache percentage for substantial cache: ${percentage}%`, + `[Audio] Boosting cache percentage for substantial cache: ${percentage}%` ); } @@ -1321,10 +1322,10 @@ export class AudioStreamManager { } console.log( - `[Audio] Cache info for ${trackId}: ${percentage}% (${fileSize} bytes, ${isFullyCached ? "full" : "partial"})`, + `[Audio] Cache info for ${trackId}: ${percentage}% (${fileSize} bytes, ${isFullyCached ? "full" : "partial"})` ); console.log( - `[Audio] Cache info details: percentage=${percentage}, displayFileSize=${displayFileSize}MB, isFullyCached=${isFullyCached}, estimatedTotal=${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB`, + `[Audio] Cache info details: percentage=${percentage}, displayFileSize=${displayFileSize}MB, isFullyCached=${isFullyCached}, estimatedTotal=${Math.round((estimatedTotalSize / 1024 / 1024) * 100) / 100}MB` ); const result = { @@ -1349,7 +1350,7 @@ export class AudioStreamManager { } catch (error) { console.error( `[Audio] Error getting cache info for track ${trackId}:`, - error, + error ); const errorResult = { percentage: 0, @@ -1383,7 +1384,7 @@ export class AudioStreamManager { */ public async isPositionCached( trackId: string, - positionMs: number, + positionMs: number ): Promise<{ isCached: boolean; estimatedCacheEndMs: number }> { try { const cacheInfo = await this.getCacheInfo(trackId); @@ -1411,7 +1412,7 @@ export class AudioStreamManager { const estimatedCacheEndMs = estimatedCacheDurationMs + bufferMs; console.log( - `[Audio] Position check for ${trackId}: position=${positionMs}ms, cached=${estimatedCacheEndMs}ms, fileSize=${cacheInfo.fileSize}MB`, + `[Audio] Position check for ${trackId}: position=${positionMs}ms, cached=${estimatedCacheEndMs}ms, fileSize=${cacheInfo.fileSize}MB` ); return { @@ -1421,7 +1422,7 @@ export class AudioStreamManager { } catch (error) { console.error( `[Audio] Error checking position cache for track ${trackId}:`, - error, + error ); return { isCached: false, estimatedCacheEndMs: 0 }; } @@ -1443,7 +1444,7 @@ export class AudioStreamManager { } catch (error) { console.warn( `[Audio] Failed to delete cached file for track ${trackId}:`, - error, + error ); } } @@ -1455,7 +1456,7 @@ export class AudioStreamManager { } catch (error) { console.warn( `[Audio] Failed to delete cached file for track ${id}:`, - error, + error ); } } @@ -1479,7 +1480,7 @@ export class AudioStreamManager { } catch (error) { console.warn( `[Audio] Failed to delete cached file for track ${trackId}:`, - error, + error ); } } @@ -1491,7 +1492,7 @@ export class AudioStreamManager { } catch (error) { console.warn( `[Audio] Failed to delete cached file for track ${id}:`, - error, + error ); } } @@ -1507,7 +1508,7 @@ export class AudioStreamManager { private async cacheYouTubeStream( streamUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log("[Audio] Skipping YouTube caching for non-remote URL"); @@ -1517,7 +1518,7 @@ export class AudioStreamManager { if (this.trackCache.has(trackId)) { const cachedPath = this.trackCache.get(trackId); console.log( - `[Audio] Using existing cached file for YouTube track: ${trackId}`, + `[Audio] Using existing cached file for YouTube track: ${trackId}` ); console.log(`[Audio] YouTube cached path: ${cachedPath}`); // Return the cached path with file:// prefix @@ -1530,7 +1531,7 @@ export class AudioStreamManager { if (this.soundCloudCache.has(trackId)) { const cachedPath = this.soundCloudCache.get(trackId); console.log( - `[Audio] Using existing cached file for YouTube track: ${trackId}`, + `[Audio] Using existing cached file for YouTube track: ${trackId}` ); console.log(`[Audio] YouTube cached path: ${cachedPath}`); // Return the cached path with file:// prefix @@ -1540,7 +1541,7 @@ export class AudioStreamManager { } console.log( - `[Audio] Starting progressive YouTube caching for track: ${trackId}`, + `[Audio] Starting progressive YouTube caching for track: ${trackId}` ); // Start background caching immediately without waiting @@ -1548,14 +1549,14 @@ export class AudioStreamManager { (error) => { console.error( `[Audio] Progressive YouTube cache failed for ${trackId}:`, - error, + error ); - }, + } ); // Return the stream URL immediately for instant playback console.log( - `[Audio] Returning stream URL immediately for track: ${trackId} (caching in background)`, + `[Audio] Returning stream URL immediately for track: ${trackId} (caching in background)` ); return streamUrl; } @@ -1568,7 +1569,7 @@ export class AudioStreamManager { private async cacheSoundCloudStream( streamUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log("[Audio] Skipping SoundCloud caching for non-remote URL"); @@ -1586,7 +1587,7 @@ export class AudioStreamManager { const cachedFilePath = await this.cacheSoundCloudStreamAsync( streamUrl, trackId, - controller, + controller ); return cachedFilePath; } catch (error) { @@ -1603,7 +1604,7 @@ export class AudioStreamManager { streamUrl: string, cacheFilePath: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log("[Audio] Skipping full track download for non-remote URL"); @@ -1614,7 +1615,7 @@ export class AudioStreamManager { const existingProgress = this.cacheProgress.get(trackId); if (existingProgress?.isDownloading) { console.log( - `[Audio] Download already in progress for track: ${trackId}`, + `[Audio] Download already in progress for track: ${trackId}` ); return; } @@ -1641,7 +1642,7 @@ export class AudioStreamManager { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if ( @@ -1649,7 +1650,7 @@ export class AudioStreamManager { fullDownloadResult.status === 206 ) { console.log( - `[Audio] Full track download completed for track: ${trackId}`, + `[Audio] Full track download completed for track: ${trackId}` ); // Check if the full download is actually significantly larger than the partial cache @@ -1660,7 +1661,7 @@ export class AudioStreamManager { const partialSize = partialFileInfo.exists ? partialFileInfo.size : 0; console.log( - `[Audio] Full file size: ${fullSize} bytes, Partial file size: ${partialSize} bytes`, + `[Audio] Full file size: ${fullSize} bytes, Partial file size: ${partialSize} bytes` ); // Only consider it a successful full download if it's significantly larger @@ -1672,7 +1673,7 @@ export class AudioStreamManager { if (fullFileInfo.exists && fullSize > 3145728 && isCompleteDownload) { // At least 3MB and complete console.log( - `[Audio] Replacing partial cache with full file for track: ${trackId}`, + `[Audio] Replacing partial cache with full file for track: ${trackId}` ); // Replace the partial cache with the full file for future plays (use generic track cache) @@ -1684,11 +1685,11 @@ export class AudioStreamManager { this.markDownloadCompleted(trackId, fullSize / (1024 * 1024)); // Convert to MB console.log( - `[Audio] Full file cache updated for track: ${trackId} (${fullSize} bytes)`, + `[Audio] Full file cache updated for track: ${trackId} (${fullSize} bytes)` ); } else { console.log( - `[Audio] Full download not significantly larger, keeping partial cache for track: ${trackId}`, + `[Audio] Full download not significantly larger, keeping partial cache for track: ${trackId}` ); // Clean up the failed full download try { @@ -1698,33 +1699,33 @@ export class AudioStreamManager { } catch (cleanupError) { console.warn( "[Audio] Failed to clean up partial full download:", - cleanupError, + cleanupError ); } } } else { // If full download fails, try downloading the rest in chunks console.log( - `[Audio] Full download failed, trying chunked download for track: ${trackId}`, + `[Audio] Full download failed, trying chunked download for track: ${trackId}` ); await this.downloadTrackInChunks( streamUrl, cacheFilePath, trackId, - controller, + controller ); } } catch (error) { console.warn( `[Audio] Full track download failed for track ${trackId}:`, - error, + error ); // Mark download as failed and check retry logic const progress = this.cacheProgress.get(trackId); if (progress && progress.retryCount < this.MAX_RETRY_ATTEMPTS) { console.log( - `[Audio] Retrying download for track ${trackId} (attempt ${progress.retryCount + 1}/${this.MAX_RETRY_ATTEMPTS})`, + `[Audio] Retrying download for track ${trackId} (attempt ${progress.retryCount + 1}/${this.MAX_RETRY_ATTEMPTS})` ); // Increment retry count @@ -1742,7 +1743,7 @@ export class AudioStreamManager { streamUrl, cacheFilePath, trackId, - controller, + controller ); } else { // Mark download as failed @@ -1754,18 +1755,18 @@ export class AudioStreamManager { }); } console.error( - `[Audio] Download failed permanently for track ${trackId} after ${progress?.retryCount || 0} attempts`, + `[Audio] Download failed permanently for track ${trackId} after ${progress?.retryCount || 0} attempts` ); // If full download fails, try downloading the rest in chunks console.log( - `[Audio] Full download failed, trying chunked download for track: ${trackId}`, + `[Audio] Full download failed, trying chunked download for track: ${trackId}` ); await this.downloadTrackInChunks( streamUrl, cacheFilePath, trackId, - controller, + controller ); } // Don't throw - this is background optimization @@ -1790,7 +1791,7 @@ export class AudioStreamManager { streamUrl: string, cacheFilePath: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log("[Audio] Skipping chunked download for non-remote URL"); @@ -1819,7 +1820,7 @@ export class AudioStreamManager { to: tempFilePath, }); console.log( - `[Audio] Copied existing cache (${totalDownloaded} bytes) to temp file`, + `[Audio] Copied existing cache (${totalDownloaded} bytes) to temp file` ); } else { // Create empty temp file for fresh download @@ -1842,7 +1843,7 @@ export class AudioStreamManager { try { console.log( - `[Audio] Downloading chunk ${currentPosition}-${endPosition} for track: ${trackId}`, + `[Audio] Downloading chunk ${currentPosition}-${endPosition} for track: ${trackId}` ); const chunkResult = await FileSystem.downloadAsync( @@ -1855,27 +1856,27 @@ export class AudioStreamManager { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if (chunkResult.status === 200 || chunkResult.status === 206) { // Append the chunk to our temp file const chunkContent = await FileSystem.readAsStringAsync( tempFilePath + ".current", - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Read existing content and append new chunk const existingContent = await FileSystem.readAsStringAsync( tempFilePath, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Decode both base64 strings to binary, concatenate, then re-encode const existingBinary = toByteArray(existingContent); const chunkBinary = toByteArray(chunkContent); const combinedBinary = new Uint8Array( - existingBinary.length + chunkBinary.length, + existingBinary.length + chunkBinary.length ); combinedBinary.set(existingBinary); combinedBinary.set(chunkBinary, existingBinary.length); @@ -1896,7 +1897,7 @@ export class AudioStreamManager { currentPosition += chunkSize; console.log( - `[Audio] Downloaded chunk, total: ${totalDownloaded} bytes`, + `[Audio] Downloaded chunk, total: ${totalDownloaded} bytes` ); // Update progress every second to avoid too frequent updates @@ -1905,7 +1906,7 @@ export class AudioStreamManager { this.updateDownloadProgress( trackId, totalDownloaded / (1024 * 1024), - 0, + 0 ); lastProgressUpdate = now; } @@ -1913,7 +1914,7 @@ export class AudioStreamManager { // If we got less data than requested, we might be at the end if (chunkSizeDownloaded < chunkSize) { console.log( - `[Audio] Reached end of file, total downloaded: ${totalDownloaded} bytes`, + `[Audio] Reached end of file, total downloaded: ${totalDownloaded} bytes` ); break; } @@ -1921,18 +1922,18 @@ export class AudioStreamManager { // If we get a 416 (Range Not Satisfiable), we've reached the end if (chunkResult.status === 416) { console.log( - `[Audio] Reached end of file (416 response) for track: ${trackId}`, + `[Audio] Reached end of file (416 response) for track: ${trackId}` ); break; } throw new Error( - `Chunk download failed with status: ${chunkResult.status}`, + `Chunk download failed with status: ${chunkResult.status}` ); } } catch (error) { console.warn( `[Audio] Chunk download failed at position ${currentPosition}:`, - error, + error ); // If we can't download more chunks, stop and use what we have break; @@ -1945,7 +1946,7 @@ export class AudioStreamManager { // Replace the original cache with our enhanced file if (totalDownloaded > 5242880) { console.log( - `[Audio] Replacing cache with enhanced file (${totalDownloaded} bytes) for track: ${trackId}`, + `[Audio] Replacing cache with enhanced file (${totalDownloaded} bytes) for track: ${trackId}` ); await FileSystem.moveAsync({ from: tempFilePath, @@ -1957,7 +1958,7 @@ export class AudioStreamManager { if (totalDownloaded > 7340032) { // More than 7MB total console.log( - `[Audio] Marking track as having substantial cache for track: ${trackId}`, + `[Audio] Marking track as having substantial cache for track: ${trackId}` ); this.trackCache.set(trackId + "_substantial", "true"); } @@ -1977,14 +1978,14 @@ export class AudioStreamManager { } catch (error) { console.warn( `[Audio] Chunked download failed for track ${trackId}:`, - error, + error ); // Check if we should retry const progress = this.cacheProgress.get(trackId); if (progress && progress.retryCount < this.MAX_RETRY_ATTEMPTS) { console.log( - `[Audio] Retrying chunked download for track ${trackId} (attempt ${progress.retryCount + 1}/${this.MAX_RETRY_ATTEMPTS})`, + `[Audio] Retrying chunked download for track ${trackId} (attempt ${progress.retryCount + 1}/${this.MAX_RETRY_ATTEMPTS})` ); // Increment retry count and wait before retry @@ -2001,7 +2002,7 @@ export class AudioStreamManager { streamUrl, cacheFilePath, trackId, - controller, + controller ); } else { // Mark download as failed @@ -2013,7 +2014,7 @@ export class AudioStreamManager { }); } console.error( - `[Audio] Chunked download failed permanently for track ${trackId} after ${progress?.retryCount || 0} attempts`, + `[Audio] Chunked download failed permanently for track ${trackId} after ${progress?.retryCount || 0} attempts` ); } } finally { @@ -2036,19 +2037,19 @@ export class AudioStreamManager { public async startProgressiveYouTubeCache( streamUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log( - "[Audio] Skipping progressive YouTube cache for non-remote URL", + "[Audio] Skipping progressive YouTube cache for non-remote URL" ); return; } console.log( - `[Audio] Starting progressive cache for YouTube track: ${trackId}`, + `[Audio] Starting progressive cache for YouTube track: ${trackId}` ); console.log( - `[Audio] Stream URL: ${streamUrl ? "present" : "missing"}, Controller: ${controller ? "present" : "missing"}`, + `[Audio] Stream URL: ${streamUrl ? "present" : "missing"}, Controller: ${controller ? "present" : "missing"}` ); // Start with a small initial chunk for quick startup @@ -2060,7 +2061,7 @@ export class AudioStreamManager { console.log(`[Audio] Got cache directory: ${cacheDir}`); if (!cacheDir) { console.warn( - "[Audio] No cache directory available for progressive caching", + "[Audio] No cache directory available for progressive caching" ); return; } @@ -2079,7 +2080,7 @@ export class AudioStreamManager { let initialResult: any = null; try { console.log( - `[Audio] Downloading initial ${initialChunkSize} bytes for quick startup`, + `[Audio] Downloading initial ${initialChunkSize} bytes for quick startup` ); console.log(`[Audio] Download URL: ${streamUrl.substring(0, 100)}...`); console.log(`[Audio] Target cache file: ${properCacheFilePath}`); @@ -2096,15 +2097,15 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); console.log( - `[Audio] Initial download result status: ${initialResult.status}`, + `[Audio] Initial download result status: ${initialResult.status}` ); console.log( "[Audio] Initial download result headers:", - initialResult.headers, + initialResult.headers ); if (initialResult.status === 200 || initialResult.status === 206) { @@ -2112,7 +2113,7 @@ export class AudioStreamManager { const fileInfo = await FileSystem.getInfoAsync(properCacheFilePath); if (fileInfo.exists) { console.log( - `[Audio] Initial chunk downloaded: ${fileInfo.size} bytes (status: ${initialResult.status})`, + `[Audio] Initial chunk downloaded: ${fileInfo.size} bytes (status: ${initialResult.status})` ); // Store in cache immediately so player can use it @@ -2126,23 +2127,23 @@ export class AudioStreamManager { { isDownloading: true, estimatedTotalSize: this.estimateTotalFileSize(fileInfo.size), - }, + } ); } else { console.warn("[Audio] File info not available for initial chunk"); } console.log( - "[Audio] Initial chunk cached, player can start immediately", + "[Audio] Initial chunk cached, player can start immediately" ); } else { console.log( - `[Audio] Initial chunk download unexpected status: ${initialResult.status}`, + `[Audio] Initial chunk download unexpected status: ${initialResult.status}` ); } } catch (initialError) { console.log( - "[Audio] Initial chunk download failed, will try full download:", + "[Audio] Initial chunk download failed, will try full download:" ); console.log( "[Audio] Error details:", @@ -2152,14 +2153,14 @@ export class AudioStreamManager { stack: initialError.stack, name: initialError.name, } - : initialError, + : initialError ); console.log( - `[Audio] Initial download status: ${initialResult?.status || "unknown"}`, + `[Audio] Initial download status: ${initialResult?.status || "unknown"}` ); console.log( "[Audio] Initial download headers:", - initialResult?.headers || "no headers", + initialResult?.headers || "no headers" ); } @@ -2168,23 +2169,23 @@ export class AudioStreamManager { (error) => { console.error( `[Audio] Background caching failed for ${trackId}:`, - error, + error ); - }, + } ); } catch (error) { console.error( `[Audio] Progressive caching setup failed for ${trackId}:`, - error, + error ); // Fallback to regular background caching this.cacheYouTubeStreamAsync(streamUrl, trackId, controller).catch( (bgError) => { console.error( `[Audio] Fallback background caching failed for ${trackId}:`, - bgError, + bgError ); - }, + } ); } } @@ -2195,11 +2196,11 @@ export class AudioStreamManager { private async cacheYouTubeStreamAsync( streamUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log( - "[Audio] Skipping background YouTube cache for non-remote URL", + "[Audio] Skipping background YouTube cache for non-remote URL" ); return streamUrl; } @@ -2211,14 +2212,14 @@ export class AudioStreamManager { } console.log( - `[Audio] Background caching first 5MB of YouTube stream for track: ${trackId}`, + `[Audio] Background caching first 5MB of YouTube stream for track: ${trackId}` ); // Get the best available cache directory const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available, skipping background caching", + "[Audio] No cache directory available, skipping background caching" ); return; } @@ -2243,7 +2244,7 @@ export class AudioStreamManager { }); // Continue without caching - return original stream URL console.log( - "[Audio] Continuing without caching due to directory issues", + "[Audio] Continuing without caching due to directory issues" ); return streamUrl; } @@ -2265,7 +2266,7 @@ export class AudioStreamManager { if (fullFileInfo.exists && fullFileInfo.size > 1048576) { // Reduced from 5MB to 1MB console.log( - `[Audio] Using existing full cached file for YouTube track: ${trackId}`, + `[Audio] Using existing full cached file for YouTube track: ${trackId}` ); this.trackCache.set(trackId, properFullFilePath); // Update progress to reflect completed state @@ -2275,7 +2276,7 @@ export class AudioStreamManager { fullFileInfo.size / (1024 * 1024), { isFullyCached: true, - }, + } ); return properFullFilePath; } @@ -2285,26 +2286,26 @@ export class AudioStreamManager { await FileSystem.getInfoAsync(properCacheFilePath); if (partialFileInfo.exists) { console.log( - `[Audio] Using existing partial cached file for YouTube track: ${trackId}`, + `[Audio] Using existing partial cached file for YouTube track: ${trackId}` ); this.trackCache.set(trackId, properCacheFilePath); // Update progress to reflect partial state const estimatedTotal = this.estimateTotalFileSize(partialFileInfo.size); const percentage = Math.min( 95, - Math.round((partialFileInfo.size / estimatedTotal) * 100), + Math.round((partialFileInfo.size / estimatedTotal) * 100) ); this.updateCacheProgress( trackId, percentage, - partialFileInfo.size / (1024 * 1024), + partialFileInfo.size / (1024 * 1024) ); return properCacheFilePath; } // Download the first 1MB (1 * 1024 * 1024 bytes) of the stream - REDUCED for faster startup console.log( - `[Audio] Downloading partial cache for YouTube track: ${trackId}`, + `[Audio] Downloading partial cache for YouTube track: ${trackId}` ); // Check if stream URL is valid @@ -2317,7 +2318,7 @@ export class AudioStreamManager { let downloadResult; try { console.log( - `[Audio] Attempting direct download from: ${streamUrl.substring(0, 50)}...`, + `[Audio] Attempting direct download from: ${streamUrl.substring(0, 50)}...` ); downloadResult = await FileSystem.downloadAsync( streamUrl, @@ -2330,15 +2331,15 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); console.log( - `[Audio] Direct download completed with status: ${downloadResult.status}`, + `[Audio] Direct download completed with status: ${downloadResult.status}` ); } catch (downloadError) { console.log( "[Audio] Direct download failed, trying with range header:", - downloadError, + downloadError ); // Fallback to range request try { @@ -2354,10 +2355,10 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); console.log( - `[Audio] Range download completed with status: ${downloadResult.status}`, + `[Audio] Range download completed with status: ${downloadResult.status}` ); } catch (rangeError) { console.error("[Audio] Range download also failed:", rangeError); @@ -2368,11 +2369,11 @@ export class AudioStreamManager { if (downloadResult.status !== 200 && downloadResult.status !== 206) { console.log( - `[Audio] Download failed with status: ${downloadResult.status}`, + `[Audio] Download failed with status: ${downloadResult.status}` ); console.log("[Audio] Response headers:", downloadResult.headers); throw new Error( - `Failed to download YouTube stream chunk: ${downloadResult.status} - ${downloadResult.headers?.["content-type"] || "unknown content type"}`, + `Failed to download YouTube stream chunk: ${downloadResult.status} - ${downloadResult.headers?.["content-type"] || "unknown content type"}` ); } @@ -2382,7 +2383,7 @@ export class AudioStreamManager { console.log("[Audio] Downloaded file info:", downloadedFileInfo); console.log( - `[Audio] Successfully cached YouTube stream ${downloadResult.headers?.["content-length"] || "unknown size"} bytes for track: ${trackId}`, + `[Audio] Successfully cached YouTube stream ${downloadResult.headers?.["content-length"] || "unknown size"} bytes for track: ${trackId}` ); // Store in cache (use generic track cache for YouTube tracks) @@ -2398,11 +2399,11 @@ export class AudioStreamManager { streamUrl, properCacheFilePath, trackId, - controller, + controller ); console.log( - `[Audio] YouTube background caching completed for track: ${trackId}`, + `[Audio] YouTube background caching completed for track: ${trackId}` ); // Return the cached file path so the player uses the local file @@ -2412,10 +2413,10 @@ export class AudioStreamManager { console.log( `[Audio] YouTube background caching failed: ${ error instanceof Error ? error.message : error - }`, + }` ); console.log( - `[Audio] YouTube stream URL: ${streamUrl.substring(0, 100)}...`, + `[Audio] YouTube stream URL: ${streamUrl.substring(0, 100)}...` ); // Try to get more error details @@ -2426,13 +2427,13 @@ export class AudioStreamManager { // Log the error but don't fail - YouTube URLs expire quickly // We'll try again on the next playback attempt console.log( - `[Audio] YouTube caching failed for ${trackId}, will retry next time`, + `[Audio] YouTube caching failed for ${trackId}, will retry next time` ); // Don't return the original stream URL since it's likely a blocked GoogleVideo URL // Instead, throw an error so the caller can try alternative approaches throw new Error( - `YouTube caching failed: ${error instanceof Error ? error.message : "Unknown error"}. The GoogleVideo CDN URL appears to be blocked.`, + `YouTube caching failed: ${error instanceof Error ? error.message : "Unknown error"}. The GoogleVideo CDN URL appears to be blocked.` ); } } @@ -2443,11 +2444,11 @@ export class AudioStreamManager { */ public async cacheYouTubeStreamPostPlayback( streamUrl: string, - trackId: string, + trackId: string ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log( - "[Audio] Skipping post-playback YouTube cache for non-remote URL", + "[Audio] Skipping post-playback YouTube cache for non-remote URL" ); return; } @@ -2458,14 +2459,14 @@ export class AudioStreamManager { } console.log( - `[Audio] Post-playback caching YouTube stream for track: ${trackId}`, + `[Audio] Post-playback caching YouTube stream for track: ${trackId}` ); // Get the best available cache directory const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available, skipping post-playback caching", + "[Audio] No cache directory available, skipping post-playback caching" ); return; } @@ -2482,7 +2483,7 @@ export class AudioStreamManager { if (fullFileInfo.exists && fullFileInfo.size > 1048576) { // Reduced from 5MB to 1MB console.log( - `[Audio] YouTube full cached file already exists for: ${trackId}`, + `[Audio] YouTube full cached file already exists for: ${trackId}` ); this.soundCloudCache.set(trackId, fullFilePath); return; @@ -2500,19 +2501,19 @@ export class AudioStreamManager { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, - }, + } ); if (downloadResult.status === 200) { console.log( - `[Audio] YouTube stream downloaded successfully for: ${trackId}`, + `[Audio] YouTube stream downloaded successfully for: ${trackId}` ); // Check file size const fileInfo = await FileSystem.getInfoAsync(cacheFilePath); if (fileInfo.exists && fileInfo.size > 0) { console.log( - `[Audio] YouTube cached file size: ${fileInfo.size} bytes`, + `[Audio] YouTube cached file size: ${fileInfo.size} bytes` ); // If file is large enough, mark it as full @@ -2527,13 +2528,13 @@ export class AudioStreamManager { } else { this.trackCache.set(trackId, cacheFilePath); console.log( - `[Audio] YouTube partial cached file saved: ${trackId}`, + `[Audio] YouTube partial cached file saved: ${trackId}` ); } } } else { console.log( - `[Audio] YouTube download failed with status: ${downloadResult.status}`, + `[Audio] YouTube download failed with status: ${downloadResult.status}` ); // Clean up partial file try { @@ -2542,7 +2543,7 @@ export class AudioStreamManager { this.clearCacheInfoCache(trackId); } catch (cleanupError) { console.log( - `[Audio] Failed to cleanup partial file: ${cleanupError}`, + `[Audio] Failed to cleanup partial file: ${cleanupError}` ); } } @@ -2558,11 +2559,11 @@ export class AudioStreamManager { private async cacheSoundCloudStreamAsync( streamUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { if (!this.isRemoteUrl(streamUrl)) { console.log( - "[Audio] Skipping background SoundCloud cache for non-remote URL", + "[Audio] Skipping background SoundCloud cache for non-remote URL" ); return streamUrl; } @@ -2576,7 +2577,7 @@ export class AudioStreamManager { const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available, returning original stream URL", + "[Audio] No cache directory available, returning original stream URL" ); return streamUrl; } @@ -2625,12 +2626,12 @@ export class AudioStreamManager { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if (downloadResult.status !== 200 && downloadResult.status !== 206) { throw new Error( - `Failed to download stream chunk: ${downloadResult.status}`, + `Failed to download stream chunk: ${downloadResult.status}` ); } @@ -2642,7 +2643,7 @@ export class AudioStreamManager { streamUrl, cacheFilePath, trackId, - controller, + controller ); // Return the cached file path so the player uses the local file @@ -2662,17 +2663,17 @@ export class AudioStreamManager { streamUrl: string, trackId: string, startPosition: number, // Position in seconds - controller: AbortController, + controller: AbortController ): Promise { console.log( - `[Audio] Caching YouTube stream from position ${startPosition}s for track: ${trackId}`, + `[Audio] Caching YouTube stream from position ${startPosition}s for track: ${trackId}` ); // Check if we already have this track cached if (this.soundCloudCache.has(trackId)) { const cachedPath = this.soundCloudCache.get(trackId); console.log( - `[Audio] Using existing cached file for YouTube track: ${trackId}`, + `[Audio] Using existing cached file for YouTube track: ${trackId}` ); return `file://${cachedPath}`; } @@ -2686,7 +2687,7 @@ export class AudioStreamManager { const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available for position-based caching", + "[Audio] No cache directory available for position-based caching" ); return streamUrl; } @@ -2698,7 +2699,7 @@ export class AudioStreamManager { // Download chunk starting from the calculated position console.log( - `[Audio] Downloading chunk from byte ${startByte} for track: ${trackId}`, + `[Audio] Downloading chunk from byte ${startByte} for track: ${trackId}` ); const downloadResult = await FileSystem.downloadAsync( @@ -2713,14 +2714,14 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if (downloadResult.status === 206) { const fileInfo = await FileSystem.getInfoAsync(cacheFilePath); if (fileInfo.exists) { console.log( - `[Audio] Position-based chunk downloaded: ${fileInfo.size} bytes`, + `[Audio] Position-based chunk downloaded: ${fileInfo.size} bytes` ); // Store in cache (use generic track cache for YouTube tracks) @@ -2734,11 +2735,11 @@ export class AudioStreamManager { { isDownloading: true, estimatedTotalSize: this.estimateTotalFileSize(fileInfo.size), - }, + } ); } else { console.warn( - "[Audio] File info not available for position-based chunk", + "[Audio] File info not available for position-based chunk" ); } @@ -2747,20 +2748,20 @@ export class AudioStreamManager { streamUrl, cacheFilePath, trackId, - controller, + controller ); return `file://${cacheFilePath}`; } else { console.log( - `[Audio] Position-based download failed with status: ${downloadResult.status}`, + `[Audio] Position-based download failed with status: ${downloadResult.status}` ); return streamUrl; } } catch (error) { console.error( `[Audio] Position-based caching failed for ${trackId}:`, - error, + error ); return streamUrl; } @@ -2774,10 +2775,10 @@ export class AudioStreamManager { streamUrl: string, trackId: string, controller: AbortController, - onProgress?: (percentage: number) => void, + onProgress?: (percentage: number) => void ): Promise { console.log( - `[Audio] Starting continuous background caching for track: ${trackId}`, + `[Audio] Starting continuous background caching for track: ${trackId}` ); try { @@ -2793,7 +2794,7 @@ export class AudioStreamManager { const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available for continuous caching", + "[Audio] No cache directory available for continuous caching" ); return; } @@ -2807,7 +2808,7 @@ export class AudioStreamManager { const fileInfo = await FileSystem.getInfoAsync(properCacheFilePath); if (!fileInfo.exists) { console.log( - `[Audio] Cache file doesn't exist, creating empty file at: ${properCacheFilePath}`, + `[Audio] Cache file doesn't exist, creating empty file at: ${properCacheFilePath}` ); await FileSystem.writeAsStringAsync(properCacheFilePath, "", { encoding: FileSystem.EncodingType.Base64, @@ -2825,13 +2826,13 @@ export class AudioStreamManager { const currentCacheInfo = await this.getCacheInfo(trackId); if (currentCacheInfo.isFullyCached) { console.log( - `[Audio] Track ${trackId} is now fully cached, stopping download`, + `[Audio] Track ${trackId} is now fully cached, stopping download` ); break; } try { console.log( - `[Audio] Downloading chunk from position ${currentPosition} for ${trackId}`, + `[Audio] Downloading chunk from position ${currentPosition} for ${trackId}` ); // Download next chunk @@ -2848,7 +2849,7 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if (chunkResult.status === 206 || chunkResult.status === 200) { @@ -2866,18 +2867,18 @@ export class AudioStreamManager { // Read both files as Base64 and combine them const existingContent = await FileSystem.readAsStringAsync( tempCombinedPath, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); const chunkContent = await FileSystem.readAsStringAsync( chunkFilePath, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Decode both base64 strings to binary, concatenate, then re-encode const existingBinary = toByteArray(existingContent); const chunkBinary = toByteArray(chunkContent); const combinedBinary = new Uint8Array( - existingBinary.length + chunkBinary.length, + existingBinary.length + chunkBinary.length ); combinedBinary.set(existingBinary); combinedBinary.set(chunkBinary, existingBinary.length); @@ -2887,7 +2888,7 @@ export class AudioStreamManager { await FileSystem.writeAsStringAsync( tempCombinedPath, combinedBase64, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Replace the original file with the combined one @@ -2905,7 +2906,7 @@ export class AudioStreamManager { } catch (chunkCombineError) { console.error( "[Audio] Error combining chunk:", - chunkCombineError, + chunkCombineError ); // Fallback: just copy the chunk file to replace the original try { @@ -2937,14 +2938,14 @@ export class AudioStreamManager { const updatedCacheInfo = await this.getCacheInfo(trackId); console.log( - `[Audio] Chunk downloaded. Cache progress: ${updatedCacheInfo.percentage}%`, + `[Audio] Chunk downloaded. Cache progress: ${updatedCacheInfo.percentage}%` ); onProgress?.(updatedCacheInfo.percentage); // Check if we're fully cached - allow completion at 95% to prevent getting stuck if (updatedCacheInfo.percentage >= 95) { console.log( - `[Audio] Track ${trackId} is now fully cached at ${updatedCacheInfo.percentage}%!`, + `[Audio] Track ${trackId} is now fully cached at ${updatedCacheInfo.percentage}%!` ); this.markDownloadCompleted(trackId, updatedCacheInfo.fileSize); break; @@ -2957,7 +2958,7 @@ export class AudioStreamManager { await new Promise((resolve) => setTimeout(resolve, 1000)); } else { console.log( - `[Audio] Chunk download failed with status: ${chunkResult.status}`, + `[Audio] Chunk download failed with status: ${chunkResult.status}` ); consecutiveErrors++; @@ -2968,7 +2969,7 @@ export class AudioStreamManager { const finalCacheInfo = await this.getCacheInfo(trackId); if (finalCacheInfo.percentage >= 95) { console.log( - `[Audio] File appears complete at ${finalCacheInfo.percentage}%, marking as fully cached`, + `[Audio] File appears complete at ${finalCacheInfo.percentage}%, marking as fully cached` ); this.markDownloadCompleted(trackId, finalCacheInfo.fileSize); } @@ -2978,7 +2979,7 @@ export class AudioStreamManager { } catch (chunkError) { console.error( `[Audio] Error downloading chunk for ${trackId}:`, - chunkError, + chunkError ); consecutiveErrors++; @@ -2997,14 +2998,14 @@ export class AudioStreamManager { finalCacheInfo.percentage < 100 ) { console.log( - `[Audio] Force completing cache at ${finalCacheInfo.percentage}% for ${trackId}`, + `[Audio] Force completing cache at ${finalCacheInfo.percentage}% for ${trackId}` ); this.markDownloadCompleted(trackId, finalCacheInfo.fileSize); } } catch (finalCheckError) { console.warn( `[Audio] Final cache check failed for ${trackId}:`, - finalCheckError, + finalCheckError ); } } catch (error) { @@ -3044,7 +3045,7 @@ export class AudioStreamManager { onStatusUpdate?: (status: string) => void, source?: string, trackTitle?: string, - trackArtist?: string, + trackArtist?: string ): Promise { // Store track information for better SoundCloud searching this.currentTrackTitle = trackTitle; @@ -3071,16 +3072,16 @@ export class AudioStreamManager { if (source === "soundcloud") { onStatusUpdate?.("Using SoundCloud strategy (exclusive)"); console.log( - `[AudioStreamManager] SoundCloud mode activated for: ${videoId}`, + `[AudioStreamManager] SoundCloud mode activated for: ${videoId}` ); try { console.log( - `[Audio] Attempting SoundCloud extraction for track: ${videoId}`, + `[Audio] Attempting SoundCloud extraction for track: ${videoId}` ); const soundCloudUrl = await this.trySoundCloud( videoId, this.currentTrackTitle, - this.currentTrackArtist, + this.currentTrackArtist ); if (soundCloudUrl) { @@ -3090,7 +3091,7 @@ export class AudioStreamManager { const cachedUrl = await this.cacheSoundCloudStream( soundCloudUrl, videoId, - controller, + controller ); return cachedUrl; } @@ -3102,12 +3103,12 @@ export class AudioStreamManager { } catch (error) { console.error( "[AudioStreamManager] SoundCloud extraction failed:", - error, + error ); throw new Error( `SoundCloud playback failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -3116,17 +3117,17 @@ export class AudioStreamManager { if (source === "youtube" || source === "yt") { onStatusUpdate?.("Using YouTube Omada API (exclusive)"); console.log( - `[AudioStreamManager] YouTube mode activated for: ${videoId}`, + `[AudioStreamManager] YouTube mode activated for: ${videoId}` ); try { console.log( - `[Audio] Attempting YouTube Omada extraction for track: ${videoId}`, + `[Audio] Attempting YouTube Omada extraction for track: ${videoId}` ); const youtubeUrl = await this.tryYouTubeOmada(videoId); if (youtubeUrl) { console.log( - `[AudioStreamManager] YouTube Omada returned URL: ${youtubeUrl.substring(0, 100)}...`, + `[AudioStreamManager] YouTube Omada returned URL: ${youtubeUrl.substring(0, 100)}...` ); // Cache the YouTube stream and return cached file path onStatusUpdate?.("Caching YouTube audio..."); @@ -3134,7 +3135,7 @@ export class AudioStreamManager { const cachedUrl = await this.cacheYouTubeStream( youtubeUrl, videoId, - controller, + controller ); return cachedUrl; } else { @@ -3145,10 +3146,10 @@ export class AudioStreamManager { // YouTube Omada strategy failed, do not try fallback strategies console.error( "[AudioStreamManager] YouTube Omada extraction failed:", - error, + error ); throw new Error( - `YouTube playback failed: ${error instanceof Error ? error.message : "Unknown error"}`, + `YouTube playback failed: ${error instanceof Error ? error.message : "Unknown error"}` ); } } @@ -3157,11 +3158,11 @@ export class AudioStreamManager { if (source === "jiosaavn") { onStatusUpdate?.("Using JioSaavn strategy (exclusive)"); console.log( - `[AudioStreamManager] JioSaavn mode activated for: ${videoId}`, + `[AudioStreamManager] JioSaavn mode activated for: ${videoId}` ); try { console.log( - `[Audio] Attempting JioSaavn extraction for track: ${videoId}`, + `[Audio] Attempting JioSaavn extraction for track: ${videoId}` ); const jioSaavnUrl = await this.tryJioSaavn(videoId); @@ -3175,10 +3176,10 @@ export class AudioStreamManager { // JioSaavn strategy failed, do not try fallback strategies console.error( "[AudioStreamManager] JioSaavn extraction failed:", - error, + error ); throw new Error( - `JioSaavn playback failed: ${error instanceof Error ? error.message : "Unknown error"}`, + `JioSaavn playback failed: ${error instanceof Error ? error.message : "Unknown error"}` ); } } @@ -3188,7 +3189,7 @@ export class AudioStreamManager { // Try concurrent testing first (ytify v8 concept) const concurrentResult = await this.testConcurrentStrategies( videoId, - onStatusUpdate, + onStatusUpdate ); if (concurrentResult) { @@ -3201,7 +3202,7 @@ export class AudioStreamManager { private async testConcurrentStrategies( videoId: string, - onStatusUpdate?: (status: string) => void, + onStatusUpdate?: (status: string) => void ): Promise { onStatusUpdate?.("Testing strategies concurrently..."); @@ -3215,7 +3216,7 @@ export class AudioStreamManager { const url = await Promise.race([ strategy(videoId), new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), 3000), + setTimeout(() => reject(new Error("Timeout")), 3000) ), ]); const latency = Date.now() - startTime; @@ -3254,15 +3255,15 @@ export class AudioStreamManager { const cachedUrl = await this.cacheYouTubeStream( fastest.url, videoId, - controller, + controller ); console.log( - `[Audio] YouTube caching completed for ${videoId}: ${cachedUrl !== fastest.url ? "cached" : "original"}`, + `[Audio] YouTube caching completed for ${videoId}: ${cachedUrl !== fastest.url ? "cached" : "original"}` ); return cachedUrl; } catch (cacheError) { console.log( - `[Audio] YouTube caching failed, using original URL: ${cacheError}`, + `[Audio] YouTube caching failed, using original URL: ${cacheError}` ); return fastest.url; } @@ -3276,7 +3277,7 @@ export class AudioStreamManager { private async testSequentialStrategies( videoId: string, - onStatusUpdate?: (status: string) => void, + onStatusUpdate?: (status: string) => void ): Promise { const errors: string[] = []; @@ -3303,15 +3304,15 @@ export class AudioStreamManager { const cachedUrl = await this.cacheYouTubeStream( url, videoId, - controller, + controller ); console.log( - `[Audio] YouTube caching completed for ${videoId}: ${cachedUrl !== url ? "cached" : "original"}`, + `[Audio] YouTube caching completed for ${videoId}: ${cachedUrl !== url ? "cached" : "original"}` ); return cachedUrl; } catch (cacheError) { console.log( - `[Audio] YouTube caching failed, using original URL: ${cacheError}`, + `[Audio] YouTube caching failed, using original URL: ${cacheError}` ); return url; } @@ -3330,7 +3331,7 @@ export class AudioStreamManager { } throw new Error( - `All audio extraction strategies failed. Errors: ${errors.join("; ")}`, + `All audio extraction strategies failed. Errors: ${errors.join("; ")}` ); } @@ -3344,10 +3345,10 @@ export class AudioStreamManager { (error) => { console.warn( `[AudioStreamManager] Prefetch failed for ${videoId}:`, - error, + error ); throw error; - }, + } ); this.prefetchQueue.set(videoId, prefetchPromise); @@ -3381,7 +3382,7 @@ export class AudioStreamManager { url: string, options: RequestInit = {}, retries = 3, - timeout = 30000, + timeout = 30000 ): Promise { for (let i = 0; i <= retries; i++) { try { @@ -3424,7 +3425,7 @@ export class AudioStreamManager { hasBlockingPage ) { throw new Error( - `Cloudflare/blocked API request: ${hasCloudflare ? "Cloudflare detected" : hasBlockingPage ? "Blocking page" : "HTML response to API request"}`, + `Cloudflare/blocked API request: ${hasCloudflare ? "Cloudflare detected" : hasBlockingPage ? "Blocking page" : "HTML response to API request"}` ); } @@ -3444,7 +3445,7 @@ export class AudioStreamManager { throw new Error("Rate limited (429): Too many requests"); } else if (response.status === 503) { throw new Error( - "Service unavailable (503): Instance may be overloaded", + "Service unavailable (503): Instance may be overloaded" ); } else if (response.status === 502) { throw new Error("Bad gateway (502): Instance proxy error"); @@ -3452,7 +3453,7 @@ export class AudioStreamManager { throw new Error("Not found (404): Resource not available"); } else if (response.status >= 500) { throw new Error( - `Server error (${response.status}): Instance may be down`, + `Server error (${response.status}): Instance may be down` ); } @@ -3461,7 +3462,7 @@ export class AudioStreamManager { const proxyController = new AbortController(); const proxyTimeoutId = setTimeout( () => proxyController.abort(), - timeout, + timeout ); const proxyResponse = await fetch(proxyUrl, { @@ -3493,7 +3494,7 @@ export class AudioStreamManager { proxyHasCloudflare ) { throw new Error( - `Cloudflare/blocked API request via proxy: ${proxyHasCloudflare ? "Cloudflare detected" : "HTML response to API request"}`, + `Cloudflare/blocked API request via proxy: ${proxyHasCloudflare ? "Cloudflare detected" : "HTML response to API request"}` ); } @@ -3517,7 +3518,7 @@ export class AudioStreamManager { const errorMessage = error instanceof Error ? error.message : String(error); console.warn( - `[AudioStreamManager] fetchWithProxy attempt ${i + 1} failed for ${url}: ${errorMessage}`, + `[AudioStreamManager] fetchWithProxy attempt ${i + 1} failed for ${url}: ${errorMessage}` ); // Don't retry on certain errors (blocking, auth, etc.) @@ -3534,7 +3535,7 @@ export class AudioStreamManager { // Exponential backoff with jitter const backoffMs = 2000 * Math.pow(2, i) + Math.random() * 1000; console.log( - `[AudioStreamManager] Waiting ${Math.round(backoffMs)}ms before retry ${i + 2}`, + `[AudioStreamManager] Waiting ${Math.round(backoffMs)}ms before retry ${i + 2}` ); await new Promise((resolve) => setTimeout(resolve, backoffMs)); } @@ -3570,7 +3571,7 @@ export class AudioStreamManager { throw new Error( `Local extraction unavailable: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -3601,7 +3602,7 @@ export class AudioStreamManager { for (const endpoint of jiosaavnEndpoints) { try { const query = encodeURIComponent( - `${cleanTitle} ${cleanAuthor}`, + `${cleanTitle} ${cleanAuthor}` ).trim(); const searchUrl = `${endpoint}?query=${query}`; @@ -3610,7 +3611,7 @@ export class AudioStreamManager { searchUrl, {}, 2, - 30000, + 30000 ); const searchData = await searchResponse.json(); @@ -3644,7 +3645,7 @@ export class AudioStreamManager { throw new Error( `JioSaavn search failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -3685,25 +3686,25 @@ export class AudioStreamManager { const audioUrl = audioMatches[0].match(/"audioUrl":"([^"]*)"/); if (audioUrl && audioUrl[1]) { return decodeURIComponent( - audioUrl[1].replace(/\\u0026/g, "&"), + audioUrl[1].replace(/\\u0026/g, "&") ); } } // Alternative: Look for adaptive formats const adaptiveMatches = html.match( - /"adaptiveFormats":\[([^\]]*)\]/, + /"adaptiveFormats":\[([^\]]*)\]/ ); if (adaptiveMatches && adaptiveMatches[1]) { try { const formats = JSON.parse(`[${adaptiveMatches[1]}]`); const audioFormats = formats.filter((f: any) => - f.mimeType?.startsWith("audio/"), + f.mimeType?.startsWith("audio/") ); if (audioFormats.length > 0) { const bestAudio = audioFormats.sort( - (a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0), + (a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0) )[0]; if (bestAudio && bestAudio.url) { return bestAudio.url; @@ -3740,7 +3741,7 @@ export class AudioStreamManager { if (data.links && data.links.mp3) { const mp3Links = data.links.mp3; const bestQuality = Object.keys(mp3Links).sort( - (a, b) => parseInt(b) - parseInt(a), + (a, b) => parseInt(b) - parseInt(a) )[0]; if (bestQuality && mp3Links[bestQuality]?.k) { return mp3Links[bestQuality].k; @@ -3758,7 +3759,7 @@ export class AudioStreamManager { throw new Error( `YouTube Music extraction failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -3798,7 +3799,7 @@ export class AudioStreamManager { throw new Error( `Hyperpipe failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -4001,8 +4002,15 @@ export class AudioStreamManager { } */ + private getOmadaProxyUrl(): string { + return ( + API.invidious.find((url) => url.includes("yt.omada.cafe")) || + "https://yt.omada.cafe" + ); + } + private async tryYouTubeOmada(videoId: string): Promise { - const instance = "https://yt.omada.cafe"; + const instance = this.getOmadaProxyUrl(); try { const requestUrl = `${instance}/api/v1/videos/${videoId}`; @@ -4019,7 +4027,7 @@ export class AudioStreamManager { }, }, 2, // 2 retries - 12000, // 12 second timeout + 12000 // 12 second timeout ); if (!response.ok) { @@ -4030,7 +4038,7 @@ export class AudioStreamManager { const contentType = response.headers.get("content-type"); if (!contentType?.includes("json")) { throw new Error( - "YouTube Omada returned HTML instead of JSON (blocked)", + "YouTube Omada returned HTML instead of JSON (blocked)" ); } @@ -4041,18 +4049,18 @@ export class AudioStreamManager { console.log( "[YouTube Omada] Found adaptiveFormats:", data.adaptiveFormats.length, - "formats", + "formats" ); const audioFormats = data.adaptiveFormats .filter( (f: any) => - f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/"), + f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/") ) .sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0)); console.log( "[YouTube Omada] Filtered audio formats:", - audioFormats.length, + audioFormats.length ); if (audioFormats.length > 0) { console.log("[YouTube Omada] Best audio format:", { @@ -4079,32 +4087,32 @@ export class AudioStreamManager { if (audioUrl.includes("googlevideo.com")) { // **SKIP HEAD TEST**: Immediately try Omada proxy for GoogleVideo URLs const googlevideoMatch = audioUrl.match( - /googlevideo\.com\/videoplayback\?(.+)/, + /googlevideo\.com\/videoplayback\?(.+)/ ); if (googlevideoMatch) { const queryParams = googlevideoMatch[1]; - audioUrl = `https://yt.omada.cafe/videoplayback?${queryParams}`; + audioUrl = `${this.getOmadaProxyUrl()}/videoplayback?${queryParams}`; useOmadaProxy = true; console.log( - `[YouTube Omada] Using Omada proxy for GoogleVideo URL (format ${i + 1}/${audioFormats.length}, bitrate: ${audioFormat.bitrate})`, + `[YouTube Omada] Using Omada proxy for GoogleVideo URL (format ${i + 1}/${audioFormats.length}, bitrate: ${audioFormat.bitrate})` ); } } console.log( - `[YouTube Omada] Attempting audio format ${i + 1}/${audioFormats.length} (bitrate: ${audioFormat.bitrate}, type: ${audioFormat.type || audioFormat.mimeType})`, + `[YouTube Omada] Attempting audio format ${i + 1}/${audioFormats.length} (bitrate: ${audioFormat.bitrate}, type: ${audioFormat.type || audioFormat.mimeType})` ); console.log( "[YouTube Omada] Audio URL:", - audioUrl.substring(0, 100) + "...", + audioUrl.substring(0, 100) + "..." ); // **RETURN IMMEDIATELY**: Don't test with HEAD, let the caching process handle failures console.log( - "[AudioStreamManager] Found audio via YouTube Omada adaptiveFormats - returning immediately", + "[AudioStreamManager] Found audio via YouTube Omada adaptiveFormats - returning immediately" ); console.log( - `[YouTube Omada] Audio format ${i + 1} selected, starting playback immediately`, + `[YouTube Omada] Audio format ${i + 1} selected, starting playback immediately` ); return audioUrl; } @@ -4112,7 +4120,7 @@ export class AudioStreamManager { // If no audio formats worked, continue to formatStreams fallback console.log( - "[YouTube Omada] All audio formats failed, trying formatStreams fallback", + "[YouTube Omada] All audio formats failed, trying formatStreams fallback" ); } @@ -4122,7 +4130,7 @@ export class AudioStreamManager { const audioStreams = data.formatStreams .filter( (f: any) => - f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/"), + f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/") ) .sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0)); @@ -4140,48 +4148,47 @@ export class AudioStreamManager { if (audioUrl.includes("googlevideo.com")) { // Convert GoogleVideo URL to Omada proxy URL const googlevideoMatch = audioUrl.match( - /googlevideo\.com\/videoplayback\?(.+)/, + /googlevideo\.com\/videoplayback\?(.+)/ ); if (googlevideoMatch) { const queryParams = googlevideoMatch[1]; - audioUrl = `https://yt.omada.cafe/videoplayback?${queryParams}`; + audioUrl = `${this.getOmadaProxyUrl()}/videoplayback?${queryParams}`; console.log( - "[YouTube Omada] Converting formatStreams GoogleVideo URL to Omada proxy", + "[YouTube Omada] Converting formatStreams GoogleVideo URL to Omada proxy" ); } } console.log( - `[YouTube Omada] Attempting formatStreams audio ${i + 1}/${audioStreams.length} (bitrate: ${audioStream.bitrate}, type: ${audioStream.type || audioStream.mimeType})`, + `[YouTube Omada] Attempting formatStreams audio ${i + 1}/${audioStreams.length} (bitrate: ${audioStream.bitrate}, type: ${audioStream.type || audioStream.mimeType})` ); // **RETURN IMMEDIATELY**: Don't test with HEAD, let the caching process handle failures console.log( - "[AudioStreamManager] Found audio via YouTube Omada formatStreams - returning immediately", + "[AudioStreamManager] Found audio via YouTube Omada formatStreams - returning immediately" ); console.log( - `[YouTube Omada] formatStreams audio ${i + 1} selected, starting playback immediately`, + `[YouTube Omada] formatStreams audio ${i + 1} selected, starting playback immediately` ); return audioUrl; } } console.log( - "[YouTube Omada] All formatStreams audio formats failed - trying video streams", + "[YouTube Omada] All formatStreams audio formats failed - trying video streams" ); } // Fallback: Try video streams and extract audio if (data.formatStreams && data.formatStreams.length > 0) { console.log( - "[YouTube Omada] Trying video streams for audio extraction", + "[YouTube Omada] Trying video streams for audio extraction" ); // Try video streams sorted by quality (lower quality = smaller file = faster download) const videoStreams = data.formatStreams .filter( (f: any) => - !f.type?.startsWith("audio/") && - !f.mimeType?.startsWith("audio/"), + !f.type?.startsWith("audio/") && !f.mimeType?.startsWith("audio/") ) .sort((a: any, b: any) => (a.bitrate || 0) - (b.bitrate || 0)); // Lower bitrate first @@ -4197,23 +4204,23 @@ export class AudioStreamManager { if (videoUrl.includes("googlevideo.com")) { // Convert GoogleVideo URL to Omada proxy URL const googlevideoMatch = videoUrl.match( - /googlevideo\.com\/videoplayback\?(.+)/, + /googlevideo\.com\/videoplayback\?(.+)/ ); if (googlevideoMatch) { const queryParams = googlevideoMatch[1]; - videoUrl = `https://yt.omada.cafe/videoplayback?${queryParams}`; + videoUrl = `${this.getOmadaProxyUrl()}/videoplayback?${queryParams}`; console.log( - "[YouTube Omada] Converting video stream GoogleVideo URL to Omada proxy", + "[YouTube Omada] Converting video stream GoogleVideo URL to Omada proxy" ); } } console.log( - `[YouTube Omada] Attempting video stream ${i + 1}/${videoStreams.length} (bitrate: ${videoStream.bitrate}, quality: ${videoStream.quality || "unknown"})`, + `[YouTube Omada] Attempting video stream ${i + 1}/${videoStreams.length} (bitrate: ${videoStream.bitrate}, quality: ${videoStream.quality || "unknown"})` ); // **SKIP HEAD TEST**: Immediately try to extract audio from video stream console.log( - "[YouTube Omada] Attempting to extract audio from video stream immediately", + "[YouTube Omada] Attempting to extract audio from video stream immediately" ); try { @@ -4221,14 +4228,14 @@ export class AudioStreamManager { const audioUrl = await this.convertStreamToMP3(videoUrl, videoId); if (audioUrl) { console.log( - "[YouTube Omada] Successfully extracted audio from video stream", + "[YouTube Omada] Successfully extracted audio from video stream" ); return audioUrl; } } catch (convertError) { console.log( `[YouTube Omada] Video stream ${i + 1} conversion failed:`, - convertError, + convertError ); } } @@ -4238,17 +4245,17 @@ export class AudioStreamManager { } throw new Error( - "No working audio formats found in YouTube Omada response. All formats failed during conversion.", + "No working audio formats found in YouTube Omada response. All formats failed during conversion." ); } catch (error) { console.error("[YouTube Omada] Complete failure details:"); console.error( "[YouTube Omada] Error type:", - error instanceof Error ? error.constructor.name : typeof error, + error instanceof Error ? error.constructor.name : typeof error ); console.error( "[YouTube Omada] Error message:", - error instanceof Error ? error.message : String(error), + error instanceof Error ? error.message : String(error) ); if (error instanceof Error && error.stack) { console.error("[YouTube Omada] Stack trace:", error.stack); @@ -4287,7 +4294,7 @@ export class AudioStreamManager { throw new Error( `YouTube embed extraction failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -4317,7 +4324,7 @@ export class AudioStreamManager { // Use a public Spotify search proxy const searchResponse = await fetch( `https://spotify-api-wrapper.onrender.com/search?q=${query}&type=track&limit=1`, - { signal: controller.signal }, + { signal: controller.signal } ); clearTimeout(timeoutId); @@ -4342,7 +4349,7 @@ export class AudioStreamManager { // Use a service to extract audio from Spotify track const extractResponse = await fetch( `https://spotify-downloader1.p.rapidapi.com/download-track?track_url=${encodeURIComponent( - track.external_urls.spotify, + track.external_urls.spotify )}`, { method: "GET", @@ -4351,7 +4358,7 @@ export class AudioStreamManager { "X-RapidAPI-Host": "spotify-downloader1.p.rapidapi.com", }, signal: controller.signal, - }, + } ); if (extractResponse.ok) { @@ -4366,7 +4373,7 @@ export class AudioStreamManager { throw new Error( `Spotify Web API failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -4375,11 +4382,11 @@ export class AudioStreamManager { videoId: string, trackTitle?: string, trackArtist?: string, - onStatusUpdate?: (status: string) => void, + onStatusUpdate?: (status: string) => void ): Promise { try { console.log( - `[Audio] trySoundCloud called with videoId: ${videoId}, title: ${trackTitle}, artist: ${trackArtist}`, + `[Audio] trySoundCloud called with videoId: ${videoId}, title: ${trackTitle}, artist: ${trackArtist}` ); // Check if this is a SoundCloud track (from our search results) @@ -4420,18 +4427,18 @@ export class AudioStreamManager { if (trackData && trackData.media?.transcodings?.length > 0) { console.log( - `[Audio] Track has ${trackData.media.transcodings.length} transcodings`, + `[Audio] Track has ${trackData.media.transcodings.length} transcodings` ); return await this.extractSoundCloudStream( trackData, - controller, + controller ); } else { console.log("[Audio] Track has no transcodings available"); } } else { console.log( - `[Audio] Direct widget failed with status: ${directResponse.status}`, + `[Audio] Direct widget failed with status: ${directResponse.status}` ); const errorText = await directResponse.text(); console.log(`[Audio] Direct widget error: ${errorText}`); @@ -4442,11 +4449,11 @@ export class AudioStreamManager { console.log( `[Audio] Direct widget attempt ${retryCount} failed: ${ retryError instanceof Error ? retryError.message : retryError - }`, + }` ); if (retryCount < maxRetries) { await new Promise((resolve) => - setTimeout(resolve, retryCount * 1000), + setTimeout(resolve, retryCount * 1000) ); } } @@ -4502,7 +4509,7 @@ export class AudioStreamManager { .join(" "); const searchUrl = `https://proxy.searchsoundcloud.com/tracks?q=${encodeURIComponent( - searchQuery, + searchQuery )}&limit=10&client_id=${this.SOUNDCLOUD_CLIENT_ID}`; console.log(`[Audio] Search URL: ${searchUrl}`); @@ -4525,13 +4532,13 @@ export class AudioStreamManager { if (searchData.collection && searchData.collection.length > 0) { // Look for exact match by track ID first const exactMatch = searchData.collection.find( - (track: any) => String(track.id) === trackId, + (track: any) => String(track.id) === trackId ); if (exactMatch) { return await this.extractSoundCloudStream( exactMatch, - controller, + controller ); } @@ -4554,19 +4561,19 @@ export class AudioStreamManager { if (titleMatch) { return await this.extractSoundCloudStream( titleMatch, - controller, + controller ); } // If no exact matches, try the first track with transcodings const availableTrack = searchData.collection.find( - (track: any) => track.media?.transcodings?.length > 0, + (track: any) => track.media?.transcodings?.length > 0 ); if (availableTrack) { return await this.extractSoundCloudStream( availableTrack, - controller, + controller ); } } @@ -4575,7 +4582,7 @@ export class AudioStreamManager { console.log( `[Audio] Search strategy failed: ${ searchError instanceof Error ? searchError.message : searchError - }`, + }` ); } } @@ -4607,14 +4614,14 @@ export class AudioStreamManager { throw new Error( `SoundCloud playback failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } private async extractSoundCloudStream( trackData: any, - controller: AbortController, + controller: AbortController ): Promise { if ( !trackData.media || @@ -4628,13 +4635,13 @@ export class AudioStreamManager { const preferredTranscoding = trackData.media.transcodings.find( (t: any) => - t.preset === "mp3_standard" && t.format?.protocol === "progressive", + t.preset === "mp3_standard" && t.format?.protocol === "progressive" ) || trackData.media.transcodings.find( - (t: any) => t.format?.protocol === "progressive", + (t: any) => t.format?.protocol === "progressive" ) || trackData.media.transcodings.find( - (t: any) => t.format?.protocol === "hls", + (t: any) => t.format?.protocol === "hls" ); if (!preferredTranscoding) { @@ -4650,7 +4657,7 @@ export class AudioStreamManager { if (trackData.track_authorization) { resolveUrl.searchParams.append( "track_authorization", - trackData.track_authorization, + trackData.track_authorization ); } @@ -4676,7 +4683,7 @@ export class AudioStreamManager { } console.log( - `[Audio] Stream validated: ${validation.contentType}, ${validation.contentLength} bytes`, + `[Audio] Stream validated: ${validation.contentType}, ${validation.contentLength} bytes` ); // For SoundCloud, we need to use the CORS proxy for the actual stream too @@ -4691,7 +4698,7 @@ export class AudioStreamManager { return await this.cacheSoundCloudStream( streamData.url, trackData.id.toString(), - controller, + controller ); } @@ -4699,7 +4706,7 @@ export class AudioStreamManager { return await this.cacheSoundCloudStream( proxiedStreamUrl, trackData.id.toString(), - controller, + controller ); } else { // No URL in response, try alternative client IDs @@ -4707,14 +4714,14 @@ export class AudioStreamManager { const altStreamUrl = await this.tryAlternativeClientIds( resolveUrl.toString(), trackData, - controller, + controller ); if (altStreamUrl) { return await this.cacheSoundCloudStream( altStreamUrl, trackData.id.toString(), - controller, + controller ); } } catch (altError) { @@ -4723,7 +4730,7 @@ export class AudioStreamManager { } } else { console.warn( - `[Audio] Failed to resolve stream. Status: ${streamResponse.status}`, + `[Audio] Failed to resolve stream. Status: ${streamResponse.status}` ); // Try alternative client IDs if primary failed @@ -4732,20 +4739,20 @@ export class AudioStreamManager { const altStreamUrl = await this.tryAlternativeClientIds( resolveUrl.toString(), trackData, - controller, + controller ); if (altStreamUrl) { return await this.cacheSoundCloudStream( altStreamUrl, trackData.id.toString(), - controller, + controller ); } } catch (altError) { console.error( "[Audio] All alternative client IDs failed after auth failure:", - altError, + altError ); } } @@ -4772,7 +4779,7 @@ export class AudioStreamManager { // Look for stream URLs in the widget HTML const streamUrlMatch = widgetHtml.match( - /\"(https?:\/\/[^\"]*\.mp3[^\"]*)\"/, + /\"(https?:\/\/[^\"]*\.mp3[^\"]*)\"/ ); if (streamUrlMatch) { // Validate the extracted URL @@ -4783,22 +4790,22 @@ export class AudioStreamManager { // Use CORS proxy for the stream URL too const proxiedWidgetStreamUrl = this.getCorsProxyUrl( - streamUrlMatch[1], + streamUrlMatch[1] ); // Validate the proxied URL const proxiedValidation = await this.validateAudioStream( - proxiedWidgetStreamUrl, + proxiedWidgetStreamUrl ); if (!proxiedValidation.isValid) { console.warn( - `[Audio] Proxied widget stream validation failed: ${proxiedValidation.error}`, + `[Audio] Proxied widget stream validation failed: ${proxiedValidation.error}` ); // Try without proxy return await this.cacheSoundCloudStream( streamUrlMatch[1], trackData.id.toString(), - controller, + controller ); } @@ -4806,7 +4813,7 @@ export class AudioStreamManager { return await this.cacheSoundCloudStream( proxiedWidgetStreamUrl, trackData.id.toString(), - controller, + controller ); } } else if ( @@ -4819,17 +4826,17 @@ export class AudioStreamManager { const altStreamUrl = await this.tryAlternativeClientIds( `https://api.soundcloud.com/i1/tracks/${trackData.id}/streams`, trackData, - controller, + controller ); if (altStreamUrl) { console.log( - "[Audio] Alternative client ID provided stream URL after widget auth failure", + "[Audio] Alternative client ID provided stream URL after widget auth failure" ); return await this.cacheSoundCloudStream( altStreamUrl, trackData.id.toString(), - controller, + controller ); } } catch (altError) { @@ -4847,7 +4854,7 @@ export class AudioStreamManager { return await this.cacheSoundCloudStream( proxiedUrl, trackData.id.toString(), - controller, + controller ); } @@ -4908,7 +4915,7 @@ export class AudioStreamManager { throw new Error( `Piped API failed: ${ error instanceof Error ? error.message : "Unknown error" - }`, + }` ); } } @@ -4918,16 +4925,12 @@ export class AudioStreamManager { // Helper method to get video info with extended timeout private async getVideoInfoWithTimeout( videoId: string, - timeout = 30000, + timeout = 30000 ): Promise<{ title?: string; author?: string }> { try { // Try multiple sources for video info const sources = [ - `https://invidious.nerdvpn.de/api/v1/videos/${videoId}`, - `https://yewtu.be/api/v1/videos/${videoId}`, - `https://invidious.f5.si/api/v1/videos/${videoId}`, - `https://inv.perditum.com/api/v1/videos/${videoId}`, - `https://inv.nadeko.net/api/v1/videos/${videoId}`, + ...API.invidious.map((url) => `${url}/videos/${videoId}`), `https://www.youtube.com/embed/${videoId}`, ]; @@ -4982,14 +4985,14 @@ export class AudioStreamManager { // Helper method to get video info private async getVideoInfo( - videoId: string, + videoId: string ): Promise<{ title?: string; author?: string }> { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); const response = await fetch( `https://invidious.nerdvpn.de/api/v1/videos/${videoId}`, - { signal: controller.signal }, + { signal: controller.signal } ); clearTimeout(timeoutId); @@ -5006,7 +5009,7 @@ export class AudioStreamManager { `https://www.youtube.com/embed/${videoId}`, { signal: controller.signal, - }, + } ); clearTimeout(timeoutId); @@ -5037,13 +5040,13 @@ export class AudioStreamManager { trackId: string, startPosition: number, controller: AbortController, - onProgress?: (percentage: number) => void, + onProgress?: (percentage: number) => void ): Promise { let resumeFilePath: string; try { console.log( - `[Audio] Resuming cache download from position ${startPosition} for track: ${trackId}`, + `[Audio] Resuming cache download from position ${startPosition} for track: ${trackId}` ); // Mark download as started to indicate active resume operation @@ -5053,7 +5056,7 @@ export class AudioStreamManager { const cacheDir = await this.getCacheDirectory(); if (!cacheDir) { console.warn( - "[Audio] No cache directory available, skipping resume download", + "[Audio] No cache directory available, skipping resume download" ); return; } @@ -5083,7 +5086,7 @@ export class AudioStreamManager { Origin: "https://www.youtube.com/", }, sessionType: FileSystem.FileSystemSessionType.BACKGROUND, - }, + } ); if (resumeResult.status === 200 || resumeResult.status === 206) { @@ -5093,7 +5096,7 @@ export class AudioStreamManager { const resumeFileInfo = await FileSystem.getInfoAsync(resumeFilePath); if (!resumeFileInfo.exists || resumeFileInfo.size === 0) { console.warn( - `[Audio] Resume file is empty or doesn't exist for track: ${trackId}`, + `[Audio] Resume file is empty or doesn't exist for track: ${trackId}` ); // Clean up resume file if it exists await FileSystem.deleteAsync(resumeFilePath, { @@ -5114,7 +5117,7 @@ export class AudioStreamManager { if (!existingFileInfo.exists || !resumeFileInfo.exists) { console.warn( - "[Audio] One of the files doesn't exist for combination", + "[Audio] One of the files doesn't exist for combination" ); return; } @@ -5132,18 +5135,18 @@ export class AudioStreamManager { // Read both files as binary arrays and combine them const existingArray = await FileSystem.readAsStringAsync( tempCombinedPath, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); const resumeArray = await FileSystem.readAsStringAsync( resumeFilePath, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Decode both base64 strings to binary, concatenate, then re-encode const existingBinary = toByteArray(existingArray); const resumeBinary = toByteArray(resumeArray); const combinedBinary = new Uint8Array( - existingBinary.length + resumeBinary.length, + existingBinary.length + resumeBinary.length ); combinedBinary.set(existingBinary); combinedBinary.set(resumeBinary, existingBinary.length); @@ -5153,7 +5156,7 @@ export class AudioStreamManager { await FileSystem.writeAsStringAsync( tempCombinedPath, combinedBase64, - { encoding: FileSystem.EncodingType.Base64 }, + { encoding: FileSystem.EncodingType.Base64 } ); // Replace the original file with the combined one @@ -5177,7 +5180,7 @@ export class AudioStreamManager { to: properCacheFilePath, }); console.log( - "[Audio] Fallback: Replaced cache file with resumed content", + "[Audio] Fallback: Replaced cache file with resumed content" ); } catch (finalError) { console.error("[Audio] Final fallback failed:", finalError); @@ -5198,20 +5201,20 @@ export class AudioStreamManager { const updatedCacheInfo = await this.getCacheInfo(trackId); onProgress?.(updatedCacheInfo.percentage); console.log( - `[Audio] Updated cache progress after resume: ${updatedCacheInfo.percentage}%`, + `[Audio] Updated cache progress after resume: ${updatedCacheInfo.percentage}%` ); // Mark download as completed this.markDownloadCompleted(trackId, updatedCacheInfo.fileSize); } else { console.log( - `[Audio] Resume download failed with status: ${resumeResult.status}`, + `[Audio] Resume download failed with status: ${resumeResult.status}` ); } } catch (error) { console.error( `[Audio] Failed to resume cache download for track ${trackId}:`, - error, + error ); // Check if it's a permission/writability error @@ -5220,7 +5223,7 @@ export class AudioStreamManager { error?.toString().includes("Permission denied") ) { console.warn( - `[Audio] Cache directory not writable, skipping resume for track ${trackId}`, + `[Audio] Cache directory not writable, skipping resume for track ${trackId}` ); // Don't retry resume for permission errors - just continue with streaming return; @@ -5271,26 +5274,26 @@ export async function getAudioStreamUrl( onStatus?: (status: string) => void, source?: string, trackTitle?: string, - trackArtist?: string, + trackArtist?: string ): Promise { return AudioStreamManager.getInstance().getAudioUrl( videoId, onStatus, source, trackTitle, - trackArtist, + trackArtist ); } export async function prefetchAudioStreamUrl( videoId: string, - source?: string, + source?: string ): Promise { return AudioStreamManager.getInstance().prefetchAudioUrl(videoId, source); } export async function prefetchAudioStreamQueue( - videoIds: string[], + videoIds: string[] ): Promise { return AudioStreamManager.getInstance().prefetchQueueItems(videoIds); } @@ -5298,12 +5301,12 @@ export async function prefetchAudioStreamQueue( export async function startProgressiveYouTubeCache( youtubeUrl: string, trackId: string, - controller: AbortController, + controller: AbortController ): Promise { return AudioStreamManager.getInstance().startProgressiveYouTubeCache( youtubeUrl, trackId, - controller, + controller ); } @@ -5311,13 +5314,13 @@ export async function cacheYouTubeStreamFromPosition( youtubeUrl: string, trackId: string, positionSeconds: number, - controller: AbortController, + controller: AbortController ): Promise { return AudioStreamManager.getInstance().cacheYouTubeStreamFromPosition( youtubeUrl, trackId, positionSeconds, - controller, + controller ); } @@ -5325,13 +5328,13 @@ export async function continueCachingTrack( streamUrl: string, trackId: string, controller: AbortController, - onProgress?: (percentage: number) => void, + onProgress?: (percentage: number) => void ): Promise { return AudioStreamManager.getInstance().continueCachingTrack( streamUrl, trackId, controller, - onProgress, + onProgress ); } @@ -5345,22 +5348,22 @@ const activeMonitors = new Set(); export async function monitorAndResumeCache( trackId: string, currentAudioUrl: string, - onProgress?: (percentage: number) => void, + onProgress?: (percentage: number) => void ): Promise { // Prevent multiple monitoring instances for the same track console.log( - `[CacheMonitor] Checking if monitoring already active for track: ${trackId}, active tracks: ${Array.from(activeMonitors).join(", ")}`, + `[CacheMonitor] Checking if monitoring already active for track: ${trackId}, active tracks: ${Array.from(activeMonitors).join(", ")}` ); if (activeMonitors.has(trackId)) { console.log( - `[CacheMonitor] Monitoring already active for track: ${trackId}, skipping duplicate`, + `[CacheMonitor] Monitoring already active for track: ${trackId}, skipping duplicate` ); return; } activeMonitors.add(trackId); console.log( - `[CacheMonitor] Starting monitoring for track: ${trackId}, total active: ${activeMonitors.size}`, + `[CacheMonitor] Starting monitoring for track: ${trackId}, total active: ${activeMonitors.size}` ); const manager = AudioStreamManager.getInstance(); let lastPercentage = 0; @@ -5389,7 +5392,7 @@ export async function monitorAndResumeCache( cacheInfo.isDownloading === false ) { console.log( - `[CacheMonitor] Found substantial partial cache (${currentPercentage}%) but no active download, attempting resume for track: ${trackId}`, + `[CacheMonitor] Found substantial partial cache (${currentPercentage}%) but no active download, attempting resume for track: ${trackId}` ); const originalStreamUrl = getOriginalStreamUrl(); @@ -5415,14 +5418,14 @@ export async function monitorAndResumeCache( trackId, currentSize, resumeController, - onProgress, + onProgress ); activeMonitors.delete(trackId); return; // Exit early only if resume succeeds } catch (resumeError: any) { console.error( `[CacheMonitor] Resume failed for track ${trackId}:`, - resumeError, + resumeError ); // If it's a permission/writability error, don't try to resume this track @@ -5431,7 +5434,7 @@ export async function monitorAndResumeCache( resumeError?.toString().includes("Permission denied") ) { console.warn( - `[CacheMonitor] Cache directory not writable, skipping resume for track ${trackId}`, + `[CacheMonitor] Cache directory not writable, skipping resume for track ${trackId}` ); activeMonitors.delete(trackId); return; // Exit monitoring for this track @@ -5449,7 +5452,7 @@ export async function monitorAndResumeCache( const originalStreamUrl = getOriginalStreamUrl(); if (originalStreamUrl) { console.log( - `[CacheMonitor] Found cached URL but no active progress, attempting resume for track: ${trackId}`, + `[CacheMonitor] Found cached URL but no active progress, attempting resume for track: ${trackId}` ); // Check if we have any cached file to resume from @@ -5473,13 +5476,13 @@ export async function monitorAndResumeCache( trackId, currentSize, resumeController, - onProgress, + onProgress ); return; // Exit early only if resume succeeds } catch (resumeError: any) { console.error( `[CacheMonitor] Resume failed for track ${trackId}:`, - resumeError, + resumeError ); // If it's a permission/writability error, don't try to resume this track @@ -5488,7 +5491,7 @@ export async function monitorAndResumeCache( resumeError?.toString().includes("Permission denied") ) { console.warn( - `[CacheMonitor] Cache directory not writable, skipping resume for track ${trackId}`, + `[CacheMonitor] Cache directory not writable, skipping resume for track ${trackId}` ); activeMonitors.delete(trackId); return; // Exit monitoring for this track @@ -5508,12 +5511,12 @@ export async function monitorAndResumeCache( ) { stuckCount++; console.log( - `[CacheMonitor] Cache appears stuck (${stuckCount}/3) for track: ${trackId}, last: ${lastPercentage}, current: ${currentPercentage}`, + `[CacheMonitor] Cache appears stuck (${stuckCount}/3) for track: ${trackId}, last: ${lastPercentage}, current: ${currentPercentage}` ); if (stuckCount >= maxStuckCount) { console.log( - `[CacheMonitor] Resuming stuck cache for track: ${trackId}`, + `[CacheMonitor] Resuming stuck cache for track: ${trackId}` ); // Resume the cache download from the last position @@ -5524,7 +5527,7 @@ export async function monitorAndResumeCache( const currentSize = fileInfo.exists ? fileInfo.size : 0; console.log( - `[CacheMonitor] Current file size: ${currentSize} bytes`, + `[CacheMonitor] Current file size: ${currentSize} bytes` ); // Get the original streaming URL from cache progress @@ -5542,13 +5545,13 @@ export async function monitorAndResumeCache( trackId, currentSize, resumeController, - onProgress, + onProgress ); stuckCount = 0; // Reset stuck counter only if resume succeeds } catch (resumeError: any) { console.error( `[CacheMonitor] Resume failed for track ${trackId}:`, - resumeError, + resumeError ); // If it's a permission/writability error, stop trying to resume this track @@ -5557,7 +5560,7 @@ export async function monitorAndResumeCache( resumeError?.toString().includes("Permission denied") ) { console.warn( - `[CacheMonitor] Cache directory not writable, stopping resume attempts for track ${trackId}`, + `[CacheMonitor] Cache directory not writable, stopping resume attempts for track ${trackId}` ); return; // Exit monitoring for this track } @@ -5567,19 +5570,19 @@ export async function monitorAndResumeCache( } } else { console.warn( - `[CacheMonitor] Cannot resume cache - no original streaming URL available for track: ${trackId}`, + `[CacheMonitor] Cannot resume cache - no original streaming URL available for track: ${trackId}` ); } } else { console.warn( - `[CacheMonitor] No cached file path found for track: ${trackId}`, + `[CacheMonitor] No cached file path found for track: ${trackId}` ); } } } else { stuckCount = 0; // Reset if progress is detected console.log( - `[CacheMonitor] Progress detected: ${lastPercentage}% -> ${currentPercentage}%`, + `[CacheMonitor] Progress detected: ${lastPercentage}% -> ${currentPercentage}%` ); } @@ -5590,7 +5593,7 @@ export async function monitorAndResumeCache( setTimeout(checkCacheProgress, 3000); // Check every 3 seconds (reduced from 5) } else { console.log( - `[CacheMonitor] Cache nearly complete (${currentPercentage}%), stopping monitoring`, + `[CacheMonitor] Cache nearly complete (${currentPercentage}%), stopping monitoring` ); // Clean up monitoring instance activeMonitors.delete(trackId); @@ -5598,7 +5601,7 @@ export async function monitorAndResumeCache( } catch (error) { console.error( `[CacheMonitor] Error monitoring cache for track ${trackId}:`, - error, + error ); // Continue monitoring even after errors if (lastPercentage < 98) { diff --git a/modules/searchAPI.ts b/modules/searchAPI.ts index 110d89b..ecd0e04 100644 --- a/modules/searchAPI.ts +++ b/modules/searchAPI.ts @@ -1,4 +1,20 @@ // lib/searchAPI.ts +import { + API, + DYNAMIC_INVIDIOUS_INSTANCES, + updateInvidiousInstances, + fetchStreamFromPipedWithFallback, + fetchStreamFromInvidiousWithFallback, + getJioSaavnSearchEndpoint, + getJioSaavnSongEndpoint, + getJioSaavnAlbumEndpoint, + getJioSaavnArtistEndpoint, + getJioSaavnPlaylistEndpoint, + fetchWithRetry, + idFromURL, + convertSStoHHMMSS, + numFormatter, +} from "../components/core/api"; export interface SearchResult { id: string; @@ -9,29 +25,28 @@ export interface SearchResult { uploaded?: string; channelUrl?: string; views?: string; + videoCount?: string; // For playlists - number of videos img?: string; thumbnailUrl?: string; source?: "youtube" | "soundcloud" | "jiosaavn" | "youtubemusic"; - type?: "song" | "album" | "artist" | "unknown"; + type?: "song" | "album" | "artist" | "playlist" | "unknown"; albumId?: string | null; albumName?: string | null; albumUrl?: string | null; albumYear?: string | null; + description?: string; // For channels - channel description + verified?: boolean; // For channels - verified badge } // --- CONSTANTS --- -const USER_AGENT = +export const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; -export const PIPED_INSTANCES = ["https://api.piped.private.coffee"]; +// Use centralized API configuration +export const PIPED_INSTANCES = API.piped; -const INVIDIOUS_INSTANCES = [ - "https://inv.nadeko.net/api/v1", - "https://vid.puffyan.us/api/v1", - "https://yewtu.be/api/v1", - "https://invidious.drgns.space/api/v1", - "https://inv.perditum.com/api/v1", -]; +// Use dynamic Invidious instances from centralized config +let INVIDIOUS_INSTANCES = DYNAMIC_INVIDIOUS_INSTANCES; /* ---------- HELPER FUNCTIONS ---------- */ const units = [ @@ -45,11 +60,11 @@ const units = [ function fmtTimeAgo(stamp: number | string | undefined): string { if (!stamp) { - return "unknown date"; + return ""; } let n = Number(stamp); - if (Number.isNaN(n)) { - return "unknown date"; + if (Number.isNaN(n) || n <= 0) { + return ""; } const ms = n > 1_000_000_000_000 ? n : n * 1000; const secDiff = (Date.now() - ms) / 1000; @@ -57,7 +72,7 @@ function fmtTimeAgo(stamp: number | string | undefined): string { return "just now"; } if (secDiff > 1_600_000_000) { - return "long ago"; + return ""; } for (const u of units) { const val = Math.floor(secDiff / u.d); @@ -68,16 +83,22 @@ function fmtTimeAgo(stamp: number | string | undefined): string { return "just now"; } +// Helper functions are now imported from centralized API configuration + // Robust fetcher for Piped/Invidious const fetchWithFallbacks = async ( instances: string[], endpoint: string ): Promise => { + console.log( + `[API] fetchWithFallbacks called with ${instances.length} instances for endpoint: ${endpoint}` + ); for (const baseUrl of instances) { const startTime = Date.now(); try { console.log(`[API] 🟡 Attempting: ${baseUrl} ...`); const url = `${baseUrl}${endpoint}`; + console.log(`[API] Full URL: ${url}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 4000); const response = await fetch(url, { @@ -85,20 +106,29 @@ const fetchWithFallbacks = async ( headers: { "User-Agent": USER_AGENT }, }); clearTimeout(timeoutId); + console.log(`[API] Response status: ${response.status}`); if (response.ok) { const text = await response.text(); + console.log(`[API] Response text length: ${text.length}`); try { if (text.trim().startsWith("{") || text.trim().startsWith("[")) { - return JSON.parse(text); + const parsed = JSON.parse(text); + console.log(`[API] ✅ Successfully parsed JSON from ${baseUrl}`); + return parsed; + } else { + console.log(`[API] Response doesn't start with JSON`); } } catch (e) { - /* ignore parse error */ + console.log(`[API] Failed to parse JSON from ${baseUrl}:`, e.message); } + } else { + console.log(`[API] Response not OK: ${response.status}`); } } catch (error) { - /* ignore network error */ + console.log(`[API] Network error for ${baseUrl}:`, error.message); } } + console.log(`[API] ❌ All instances failed for endpoint: ${endpoint}`); return null; }; @@ -138,7 +168,7 @@ export const searchAPI = { } const endpoint = `/suggestions?query=${encodeURIComponent(query)}`; - const data = await fetchWithFallbacks(PIPED_INSTANCES, endpoint); + const data = await fetchWithFallbacks([...PIPED_INSTANCES], endpoint); let suggestions: string[] = []; if (Array.isArray(data)) { @@ -254,26 +284,18 @@ export const searchAPI = { console.log(`[API] Starting JioSaavn search for: "${query}"`); try { - const searchUrl = `https://streamifyjiosaavn.vercel.app/api/search?query=${encodeURIComponent(query)}`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(searchUrl, { - signal: controller.signal, - headers: { - "User-Agent": USER_AGENT, - Accept: "application/json", + const searchUrl = getJioSaavnSearchEndpoint(query); + const data = await fetchWithRetry( + searchUrl, + { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); + 3, + 1000 + ); if (!data || !data.success || !data.data) { throw new Error("Invalid response format"); @@ -523,26 +545,18 @@ export const searchAPI = { console.log(`[API] Fetching JioSaavn song details for: "${songId}"`); try { - const detailsUrl = `https://streamifyjiosaavn.vercel.app/api/songs/${songId}`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(detailsUrl, { - signal: controller.signal, - headers: { - "User-Agent": USER_AGENT, - Accept: "application/json", + const detailsUrl = getJioSaavnSongEndpoint(songId); + const data = await fetchWithRetry( + detailsUrl, + { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); + 3, + 1000 + ); if ( !data || @@ -577,8 +591,9 @@ export const searchAPI = { id: String(song.id), title: song.name || song.title || song.song || "Unknown Title", artist: - song.artists?.primary?.map((artist: any) => artist.name).join(", ") || - "Unknown Artist", + song.artists?.primary + ?.map((artist: any) => artist.name?.replace(/\s*-\s*Topic$/i, "")) + .join(", ") || "Unknown Artist", duration: song.duration || 0, thumbnail: thumbnailUrl, audioUrl: audioUrl, @@ -602,52 +617,50 @@ export const searchAPI = { try { // Strategy 1: Try direct album endpoint first - const albumUrl = `https://streamifyjiosaavn.vercel.app/api/albums?id=${albumId}`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout + const albumUrl = getJioSaavnAlbumEndpoint(albumId); try { - const albumResponse = await fetch(albumUrl, { - signal: controller.signal, - headers: { - "User-Agent": USER_AGENT, - Accept: "application/json", + const albumData = await fetchWithRetry( + albumUrl, + { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, }, - }); - - if (albumResponse.ok) { - const albumData = await albumResponse.json(); + 3, + 1000 + ); - if ( - albumData && - albumData.success && - albumData.data && - albumData.data.songs - ) { - console.log( - `[API] 🟢 JioSaavn Album Details Success (Direct): Found ${albumData.data.songs.length} songs for "${albumName}"` - ); + if ( + albumData && + albumData.success && + albumData.data && + albumData.data.songs + ) { + console.log( + `[API] 🟢 JioSaavn Album Details Success (Direct): Found ${albumData.data.songs.length} songs for "${albumName}"` + ); - return { - id: albumId, - name: albumName, - year: albumData.data.year || albumData.data.songs[0]?.year || "", - image: - albumData.data.image || albumData.data.songs[0]?.image || [], - songs: albumData.data.songs, - artists: - albumData.data.artists || - albumData.data.songs[0]?.artists?.primary - ?.map((artist: any) => artist.name) - .join(", ") || - "", - language: - albumData.data.language || - albumData.data.songs[0]?.language || - "", - }; - } + return { + id: albumId, + name: albumName, + year: albumData.data.year || albumData.data.songs[0]?.year || "", + image: albumData.data.image || albumData.data.songs[0]?.image || [], + songs: albumData.data.songs, + artists: + albumData.data.artists || + albumData.data.songs[0]?.artists?.primary + ?.map((artist: any) => + artist.name?.replace(/\s*-\s*Topic$/i, "") + ) + .join(", ") || + "", + language: + albumData.data.language || + albumData.data.songs[0]?.language || + "", + }; } } catch (albumError) { console.log( @@ -665,21 +678,19 @@ export const searchAPI = { for (const query of searchQueries) { try { - const searchUrl = `https://streamifyjiosaavn.vercel.app/api/search/songs?query=${encodeURIComponent(query)}`; - - const searchResponse = await fetch(searchUrl, { - signal: controller.signal, - headers: { - "User-Agent": USER_AGENT, - Accept: "application/json", + const controller = new AbortController(); + const data = await fetchWithRetry( + getJioSaavnSearchEndpoint(query), + { + signal: controller.signal, + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, }, - }); - - if (!searchResponse.ok) { - continue; - } - - const data = await searchResponse.json(); + 3, + 1000 + ); if (!data || !data.success || !data.data || !data.data.results) { continue; @@ -713,7 +724,9 @@ export const searchAPI = { songs: albumSongs, artists: albumSongs[0].artists?.primary - ?.map((artist: any) => artist.name) + ?.map((artist: any) => + artist.name?.replace(/\s*-\s*Topic$/i, "") + ) .join(", ") || "", language: albumSongs[0].language || "", }; @@ -734,13 +747,183 @@ export const searchAPI = { } }, + // --- YOUTUBE PLAYLIST DETAILS --- + getYouTubePlaylistDetails: async (playlistId: string) => { + console.log( + `[API] Fetching YouTube playlist details for ID: ${playlistId}` + ); + + // Extract playlist ID from URL format if needed + let actualPlaylistId = playlistId; + if (playlistId.includes("/playlist?list=")) { + actualPlaylistId = playlistId.split("list=")[1] || playlistId; + console.log(`[API] Extracted playlist ID from URL: ${actualPlaylistId}`); + } + + try { + // First, try Piped API + const endpoint = `/playlists/${actualPlaylistId}`; + console.log( + `[API] Calling fetchWithFallbacks with endpoint: ${endpoint}` + ); + const data = await fetchWithFallbacks([...PIPED_INSTANCES], endpoint); + console.log( + `[API] fetchWithFallbacks returned:`, + data ? "data object" : "null" + ); + + // If no data returned from any instance, return null (no fallback) + if (!data) { + console.warn("[API] No data returned from any Piped instance"); + return null; + } + + // Enhanced debugging for response structure + console.log("[API] Response data keys:", Object.keys(data)); + console.log("[API] Response data type:", typeof data); + + // Check for different possible response structures + if (data.error) { + console.warn("[API] API returned error:", data.error); + return null; + } + + // Handle different response formats from different Piped instances + let videos = null; + let playlistName = data.name || data.title || "Unknown Playlist"; + let playlistDescription = data.description || ""; + let playlistThumbnail = data.thumbnailUrl || data.thumbnail || ""; + + // Check for videos in various possible fields + if (data.videos && Array.isArray(data.videos) && data.videos.length > 0) { + videos = data.videos; + console.log(`[API] Using 'videos' field with ${videos.length} videos`); + } else if ( + data.relatedStreams && + Array.isArray(data.relatedStreams) && + data.relatedStreams.length > 0 + ) { + videos = data.relatedStreams; + console.log( + `[API] Using 'relatedStreams' field with ${videos.length} videos` + ); + } else if ( + data.items && + Array.isArray(data.items) && + data.items.length > 0 + ) { + videos = data.items; + console.log(`[API] Using 'items' field with ${videos.length} videos`); + } else if ( + data.content && + Array.isArray(data.content) && + data.content.length > 0 + ) { + videos = data.content; + console.log(`[API] Using 'content' field with ${videos.length} videos`); + } + + // If we found videos, validate they have the required fields + if (videos && videos.length > 0) { + console.log( + "[API] First video structure:", + JSON.stringify(videos[0], null, 2) + ); + + // Filter out invalid videos and map to standard format + const validVideos = videos + .filter((video: any) => { + // Check if video has at least a title or URL + const hasTitle = video.title && typeof video.title === "string"; + const hasUrl = video.url || video.videoId || video.id; + const isValid = hasTitle || hasUrl; + if (!isValid) { + console.warn("[API] Skipping invalid video:", video); + } + return isValid; + }) + .map((video: any) => { + // Extract video ID from various possible formats + let videoId = ""; + if (video.videoId) { + videoId = String(video.videoId); + } else if (video.id) { + videoId = String(video.id); + } else if (video.url && typeof video.url === "string") { + // Try to extract video ID from YouTube URL + const match = video.url.match(/[?&]v=([^&]+)/); + videoId = match ? match[1] : video.url; + } + + return { + id: videoId, + title: video.title || "Unknown Title", + artist: + video.uploaderName || + video.uploader || + video.author || + "Unknown Artist", + duration: video.duration || video.lengthSeconds || 0, + thumbnail: video.thumbnail || video.thumbnailUrl || "", + views: String(video.views || video.viewCount || 0), + uploaded: + video.uploadedDate || video.uploaded || video.published || "", + }; + }); + + if (validVideos.length > 0) { + console.log( + `[API] 🟢 YouTube Playlist Success: Found ${validVideos.length} valid videos` + ); + + // Use first valid video's thumbnail if playlist thumbnail is not available + if (!playlistThumbnail && validVideos[0].thumbnail) { + playlistThumbnail = validVideos[0].thumbnail; + } + + const result = { + id: playlistId, + name: playlistName, + description: playlistDescription, + thumbnail: playlistThumbnail, + videos: validVideos, + }; + + console.log( + `[API] Returning playlist with ${result.videos.length} videos` + ); + return result; + } else { + console.warn("[API] No valid videos found after filtering"); + } + } else { + console.warn("[API] No videos found in any field"); + } + + // If we reach here, we have data but no valid videos + console.warn("[API] Invalid playlist response format"); + console.warn("[API] Available data keys:", Object.keys(data)); + console.warn( + "[API] Data structure preview:", + JSON.stringify(data).substring(0, 500) + ); + return null; + } catch (e: any) { + console.warn(`[API] 🔴 YouTube Playlist Details Error: ${e.message}`); + return null; + } + }, + searchWithPiped: async ( query: string, filter: string, page?: number, - limit?: number + limit?: number, + nextpage?: string ) => { - console.log(`[API] Searching Piped: "${query}"`); + console.log( + `[API] Searching Piped: "${query}", page: ${page}, nextpage: ${nextpage ? "present" : "none"}` + ); // Enhanced multilingual search - preserve original query but also try transliterated version const searchQueries = [query]; @@ -754,29 +937,69 @@ export const searchAPI = { const filterParam = filter === "" ? "all" : filter; - // Try the primary query first - const endpoint = `/search?q=${encodeURIComponent( - query - )}&filter=${filterParam}`; - const data = await fetchWithFallbacks(PIPED_INSTANCES, endpoint); + // Use nextpage endpoint if we have a nextpage token (for pagination) + let endpoint: string; + if (nextpage) { + console.log( + `[API] Using nextpage endpoint with token: ${nextpage.substring(0, 50)}...` + ); + endpoint = `/nextpage/search?nextpage=${encodeURIComponent(nextpage)}`; + } else { + // Initial search + endpoint = `/search?q=${encodeURIComponent(query)}&filter=${filterParam}`; + } + + const data = await fetchWithFallbacks([...PIPED_INSTANCES], endpoint); - // If no results and we have multilingual query, try with relaxed search terms + // If no results and we have multilingual query, try with relaxed search terms (only for initial search) if ( (!data || !Array.isArray(data.items) || data.items.length === 0) && - /[^\u0000-\u007F]/.test(query) + /[^\u0000-\u007F]/.test(query) && + !nextpage ) { console.log( "[API] No results for multilingual query, trying broader search" ); const broadEndpoint = `/search?q=${encodeURIComponent(query)}&filter=all`; const broadData = await fetchWithFallbacks( - PIPED_INSTANCES, + [...PIPED_INSTANCES], broadEndpoint ); - return broadData && Array.isArray(broadData.items) ? broadData.items : []; + return { + items: + broadData && Array.isArray(broadData.items) ? broadData.items : [], + nextpage: broadData?.nextpage || null, + }; + } + + // If no results with specific filter (like "channels"), try with "all" filter as fallback (only for initial search) + if ( + (!data || !Array.isArray(data.items) || data.items.length === 0) && + filterParam !== "all" && + !nextpage + ) { + console.log( + `[API] No results with filter "${filterParam}", trying with "all" filter` + ); + const fallbackEndpoint = `/search?q=${encodeURIComponent(query)}&filter=all`; + const fallbackData = await fetchWithFallbacks( + [...PIPED_INSTANCES], + fallbackEndpoint + ); + return { + items: + fallbackData && Array.isArray(fallbackData.items) + ? fallbackData.items + : [], + nextpage: fallbackData?.nextpage || null, + }; } - return data && Array.isArray(data.items) ? data.items : []; + // Return both items and nextpage token for proper pagination + return { + items: data && Array.isArray(data.items) ? data.items : [], + nextpage: data?.nextpage || null, + }; }, searchWithInvidious: async ( @@ -785,11 +1008,12 @@ export const searchAPI = { page?: number, limit?: number ) => { - console.log(`[API] Searching Invidious: "${query}"`); + console.log(`[API] Searching Invidious: "${query}", page: ${page || 1}`); const sortParam = sortType === "date" ? "upload_date" : "view_count"; + const pageParam = page && page > 1 ? `&page=${page}` : ""; const endpoint = `/search?q=${encodeURIComponent( query - )}&sort_by=${sortParam}`; + )}&sort_by=${sortParam}${pageParam}`; const data = await fetchWithFallbacks(INVIDIOUS_INSTANCES, endpoint); return Array.isArray(data) ? data : []; }, @@ -872,9 +1096,29 @@ export const searchAPI = { query: string, filter: string, page?: number, - limit?: number + limit?: number, + nextpage?: string ) => { - return searchAPI.searchWithPiped(query, filter, page, limit); + // Map YouTube Music filter names to Piped music filter names + const musicFilterMap: Record = { + songs: "music_songs", + videos: "music_videos", + albums: "music_albums", + playlists: "music_playlists", + channels: "music_artists", // UI uses "channels" for artists + artists: "music_artists", + all: "music_songs", // Default to songs for "all" in YouTube Music + "": "music_songs", // Default to songs when no filter is specified + }; + + // Convert the filter to the appropriate YouTube Music filter + const musicFilter = musicFilterMap[filter] || filter; + + console.log( + `[API] YouTube Music search: "${query}", filter: "${filter}" -> "${musicFilter}"` + ); + + return searchAPI.searchWithPiped(query, musicFilter, page, limit, nextpage); }, formatSearchResults: (results: any[]): SearchResult[] => { @@ -911,7 +1155,22 @@ export const searchAPI = { item.url && typeof item.url === "string" && item.url.startsWith("/watch"); - let id = isPiped ? item.url.split("v=")[1] : item.videoId || ""; + + // Handle different ID types (videos, channels, playlists) + let id = ""; + if (item.url && item.url.includes("/channel/")) { + // Channel ID + id = item.url.split("/channel/")[1] || item.channelId || ""; + } else if (item.url && item.url.includes("/playlist?list=")) { + // Playlist ID + id = item.url.split("list=")[1] || item.playlistId || ""; + } else if (isPiped) { + // Video ID + id = item.url.split("v=")[1] || ""; + } else { + // Fallback to videoId + id = item.videoId || ""; + } let thumbnailUrl = item.thumbnail || ""; if ( !thumbnailUrl && @@ -920,21 +1179,127 @@ export const searchAPI = { ) { thumbnailUrl = item.videoThumbnails[0].url; } + + // Determine item type based on available data + let itemType: "song" | "album" | "artist" | "playlist" | "unknown" = + "unknown"; + + // Channel detection - previous format + if ( + item.channelId || + item.type === "channel" || + (item.url && item.url.includes("/channel/")) + ) { + itemType = "artist"; // Channel/artist + } else if ( + item.playlistId || + item.type === "playlist" || + (item.url && item.url.includes("/playlist?list=")) + ) { + itemType = "playlist"; // Playlist + } else if ( + item.duration || + item.lengthSeconds || + item.videoId || + item.type === "video" + ) { + itemType = "song"; // Video/song + } + + // Handle channel/artist items - JioSaavn style format + if (itemType === "artist") { + const result: SearchResult = { + id, + title: + item.name || item.title || item.uploaderName || "Unknown Channel", + author: item.uploaderName || item.author || "Unknown Artist", + duration: String(item.duration || item.lengthSeconds || "0"), + views: String(item.views || item.viewCount || "0"), + videoCount: undefined, // Channels don't have video count + uploaded: fmtTimeAgo( + Number(item.published || item.uploaded || Date.now()) + ), + thumbnailUrl, + img: thumbnailUrl, + href: + item.url || + (item.channelId ? `/channel/${id}` : `/channel/${id}`), + source: "jiosaavn", // Use JioSaavn source for proper 1:1 thumbnail display + type: itemType, + description: item.description || "", // Add channel description + verified: item.verified || false, // Add verified badge + }; + return result; + } + + // Handle playlist items - previous format + if (itemType === "playlist") { + const result: SearchResult = { + id, + title: item.title || item.name || "Unknown Playlist", + author: item.uploaderName || item.author || "Unknown Creator", + duration: String(item.duration || item.lengthSeconds || "0"), + views: String(item.views || item.viewCount || "0"), + videoCount: String( + item.videoCount && item.videoCount > 0 + ? item.videoCount + : item.videos && item.videos > 0 + ? item.videos + : "" + ), // Keep for visual layers but hide badge + uploaded: fmtTimeAgo( + Number(item.published || item.uploaded || Date.now()) + ), + thumbnailUrl, + img: thumbnailUrl, + href: + item.url || + (item.playlistId + ? `/playlist?list=${id}` + : `/playlist?list=${id}`), + source: "youtube", + type: itemType, + }; + return result; + } + + // Handle video/song items (default) const result: SearchResult = { id, title: item.title || "Unknown Title", author: item.uploaderName || item.author || "Unknown Artist", duration: String(item.duration || item.lengthSeconds || "0"), views: String(item.views || item.viewCount || "0"), - uploaded: fmtTimeAgo(Number(item.published || item.uploaded)), + videoCount: undefined, + uploaded: fmtTimeAgo( + Number(item.published || item.uploaded || Date.now()) + ), thumbnailUrl, img: thumbnailUrl, - href: isPiped ? item.url : `/watch?v=${id}`, + href: item.url || `/watch?v=${id}`, source: "youtube", + type: itemType, }; return result; }) - .filter((item): item is SearchResult => item !== null && item.id !== ""); + .filter((item): item is SearchResult => { + if (item === null || item.id === "") return false; + + // Filter out album items for YouTube and YouTube Music sources + if ( + item.type === "album" && + (item.source === "youtube" || item.source === "youtubemusic") + ) { + return false; + } + + // Filter out items with video count of -2 + if (item.videoCount === "-2") { + return false; + } + + return true; + }); }, // --- SOUNDCLOUD PROXY API --- @@ -987,4 +1352,108 @@ export const searchAPI = { return []; } }, + + // --- YOUTUBE FALLBACK FUNCTIONS --- + + /** + * Fallback YouTube search using both Piped and Invidious instances + * Tries Piped first, then falls back to Invidious if Piped fails + */ + searchYouTubeWithFallback: async ( + query: string, + filter: string = "all", + page?: number, + limit?: number + ) => { + console.log( + `[API] YouTube fallback search: "${query}", filter: "${filter}"` + ); + + // Try Piped first + try { + console.log("[API] Attempting Piped search first..."); + const pipedResults = await searchAPI.searchWithPiped( + query, + filter, + page, + limit + ); + if ( + pipedResults && + Array.isArray(pipedResults.items) && + pipedResults.items.length > 0 + ) { + console.log( + `[API] Piped search successful, found ${pipedResults.items.length} results` + ); + return pipedResults.items; + } + console.log( + "[API] Piped search returned no results, trying Invidious..." + ); + } catch (error) { + console.log("[API] Piped search failed:", error.message); + console.log("[API] Trying Invidious search..."); + } + + // Fallback to Invidious + try { + const invidiousResults = await searchAPI.searchWithInvidious( + query, + "relevance", + page, + limit + ); + if (invidiousResults && invidiousResults.length > 0) { + console.log( + `[API] Invidious search successful, found ${invidiousResults.length} results` + ); + return invidiousResults; + } + console.log("[API] Invidious search also returned no results"); + } catch (error) { + console.log("[API] Invidious search also failed:", error.message); + } + + console.log("[API] Both Piped and Invidious searches failed"); + return []; + }, + + /** + * Fallback YouTube video info/playback using both Piped and Invidious instances + * Tries Piped first, then falls back to Invidious if Piped fails + */ + getYouTubeVideoInfoWithFallback: async (videoId: string) => { + console.log(`[API] YouTube fallback video info: "${videoId}"`); + + // Use the centralized fallback function + try { + const result = await fetchStreamFromPipedWithFallback(videoId); + console.log("[API] Piped video info successful"); + return { + success: true, + source: "piped", + data: result, + }; + } catch (pipedError) { + console.log("[API] Piped video info failed, trying Invidious..."); + + try { + const result = await fetchStreamFromInvidiousWithFallback(videoId); + console.log("[API] Invidious video info successful"); + return { + success: true, + source: "invidious", + data: result, + }; + } catch (invidiousError) { + console.log("[API] Both Piped and Invidious video info failed"); + return { + success: false, + source: null, + data: null, + }; + } + } + }, }; diff --git a/modules/store.ts b/modules/store.ts index 6f05eb1..ee82ab0 100644 --- a/modules/store.ts +++ b/modules/store.ts @@ -1,5 +1,8 @@ export const params = new URL(location.href).searchParams; +// Import centralized API configuration +import { API } from "../components/core/api"; + // Import missing types type CollectionItem = { id: string; @@ -67,7 +70,7 @@ if (savedStore) { export function setState( key: K, - val: AppSettings[K], + val: AppSettings[K] ) { state[key] = val; const str = JSON.stringify(state); @@ -117,7 +120,7 @@ export const store: { hls: { src: () => "", manifests: [], - api: ["https://api.piped.private.coffee"], + api: [...API.piped], }, supportsOpus: navigator.mediaCapabilities .decodingInfo({ @@ -147,15 +150,9 @@ export const store: { }, streamHistory: [], api: { - piped: ["https://api.piped.private.coffee"], + piped: [...API.piped], proxy: [], - invidious: [ - "https://yewtu.be", - "https://inv.nadeko.net", - "https://invidious.nerdvpn.de/", - "https://invidious.f5.si/", - "https://inv.perditum.com/", - ], + invidious: API.invidious.map((url) => url.replace("/api/v1", "")), hyperpipe: ["https://hyperpipeapi.onrender.com"], jiosaavn: "https://saavn.dev", status: "P", diff --git a/services/TrackPlayerService.ts b/services/TrackPlayerService.ts index f0434e7..bb4fe8e 100644 --- a/services/TrackPlayerService.ts +++ b/services/TrackPlayerService.ts @@ -1,6 +1,6 @@ import TrackPlayer, { - Event, - Capability, + Event as TrackPlayerEvent, + Capability as TrackPlayerCapability, RepeatMode, AppKilledPlaybackBehavior, State, @@ -8,11 +8,150 @@ import TrackPlayer, { IOSCategoryMode, IOSCategoryOptions, PitchAlgorithm, -} from "react-native-track-player"; +} from "../utils/safeTrackPlayer"; import { t } from "../utils/localization"; import { Platform, NativeModules } from "react-native"; import { Track } from "../contexts/PlayerContext"; +// Comprehensive fallback constants for TrackPlayer capabilities +const CAPABILITY_FALLBACKS = { + Play: "play", + Pause: "pause", + SkipToNext: "skipToNext", + SkipToPrevious: "skipToPrevious", + Stop: "stop", + SeekTo: "seekTo", + JumpForward: "jumpForward", + JumpBackward: "jumpBackward", + Like: "like", + Dislike: "dislike", + Bookmark: "bookmark", +} as const; + +// Fallback constants for TrackPlayer events +const EVENT_FALLBACKS = { + RemotePlay: "remote-play", + RemotePause: "remote-pause", + RemoteStop: "remote-stop", + RemoteNext: "remote-next", + RemotePrevious: "remote-previous", + RemoteSeek: "remote-seek", + RemoteDuck: "remote-duck", + PlaybackQueueEnded: "playback-queue-ended", + PlaybackTrackChanged: "playback-track-changed", + PlaybackProgressUpdated: "playback-progress-updated", +} as const; + +// Safe capability constants with fallbacks +const Capability = TrackPlayerCapability || CAPABILITY_FALLBACKS; +const Event = TrackPlayerEvent || EVENT_FALLBACKS; + +// Additional safety check for individual capability constants +const getSafeCapability = ( + capabilityName: keyof typeof CAPABILITY_FALLBACKS, +): any => { + try { + // Return the actual Capability enum value if available, otherwise use fallback string + return TrackPlayerCapability + ? TrackPlayerCapability[capabilityName] + : CAPABILITY_FALLBACKS[capabilityName]; + } catch (error) { + console.warn( + `[TrackPlayerService] Failed to get capability ${capabilityName}, using fallback`, + ); + return CAPABILITY_FALLBACKS[capabilityName]; + } +}; + +// Additional safety check for individual event constants +const getSafeEvent = (eventName: keyof typeof EVENT_FALLBACKS): any => { + try { + return Event[eventName] || EVENT_FALLBACKS[eventName]; + } catch (error) { + console.warn( + `[TrackPlayerService] Failed to get event ${eventName}, using fallback`, + ); + return EVENT_FALLBACKS[eventName]; + } +}; + +// Module-level initialization check +const initializeTrackPlayerConstants = () => { + try { + console.log("[TrackPlayerService] Initializing TrackPlayer constants..."); + + // Check if TrackPlayer module is available + if (!TrackPlayer) { + console.error("[TrackPlayerService] TrackPlayer module is null!"); + return false; + } + + // Check if native module is available + const nativeTrackPlayer = + (NativeModules as any).TrackPlayerModule || + (NativeModules as any).TrackPlayer; + + if (!nativeTrackPlayer) { + console.error("[TrackPlayerService] Native TrackPlayer module is null!"); + return false; + } + + // Validate capability constants + const capabilityCheck = { + original: TrackPlayerCapability, + fallback: CAPABILITY_FALLBACKS, + usingFallback: !TrackPlayerCapability, + }; + + const eventCheck = { + original: TrackPlayerEvent, + fallback: EVENT_FALLBACKS, + usingFallback: !TrackPlayerEvent, + }; + + console.log( + "[TrackPlayerService] Capability constants check:", + capabilityCheck, + ); + console.log("[TrackPlayerService] Event constants check:", eventCheck); + + // Test if we can access the constants without errors + if (TrackPlayerCapability) { + try { + const testCapabilities = [ + TrackPlayerCapability.Play, + TrackPlayerCapability.Pause, + TrackPlayerCapability.SkipToNext, + TrackPlayerCapability.SkipToPrevious, + ]; + console.log( + "[TrackPlayerService] Successfully accessed capability constants", + ); + } catch (error) { + console.warn( + "[TrackPlayerService] Error accessing capability constants, will use fallbacks:", + error, + ); + } + } + + return true; + } catch (error) { + console.error( + "[TrackPlayerService] Failed to initialize TrackPlayer constants:", + error, + ); + return false; + } +}; + +// Initialize constants immediately when module loads +const constantsInitialized = initializeTrackPlayerConstants(); +console.log( + "[TrackPlayerService] Constants initialization result:", + constantsInitialized, +); + // TurboModule compatibility workaround const setupTurboModuleCompatibility = () => { try { @@ -31,7 +170,7 @@ const setupTurboModuleCompatibility = () => { return originalGetConstants.call(this); } catch (e) { console.warn( - "[TrackPlayerService] TurboModule getConstants error, returning safe defaults" + "[TrackPlayerService] TurboModule getConstants error, returning safe defaults", ); return { STATE_NONE: 0, @@ -62,13 +201,21 @@ const setupTurboModuleCompatibility = () => { } catch (e) { console.warn( `[TrackPlayerService] TurboModule ${method} error:`, - e + e, ); // Return safe defaults for sync methods - if (method === "getState") return Promise.resolve(0); - if (method === "getPosition") return Promise.resolve(0); - if (method === "getDuration") return Promise.resolve(0); - if (method === "getBufferedPosition") return Promise.resolve(0); + if (method === "getState") { + return Promise.resolve(0); + } + if (method === "getPosition") { + return Promise.resolve(0); + } + if (method === "getDuration") { + return Promise.resolve(0); + } + if (method === "getBufferedPosition") { + return Promise.resolve(0); + } return Promise.resolve(0); } }; @@ -80,7 +227,7 @@ const setupTurboModuleCompatibility = () => { } catch (error) { console.warn( "[TrackPlayerService] Failed to apply TurboModule compatibility layer:", - error + error, ); } }; @@ -100,7 +247,7 @@ export class TrackPlayerService { } catch (error) { console.error( "[TrackPlayerService] Failed to create TrackPlayerService instance:", - error + error, ); throw error; } @@ -112,7 +259,7 @@ export class TrackPlayerService { // Check if TrackPlayer is available and initialized if (!TrackPlayer) { throw new Error( - "TrackPlayer is not available - make sure react-native-track-player is properly installed" + "TrackPlayer is not available - make sure react-native-track-player is properly installed", ); } @@ -123,7 +270,7 @@ export class TrackPlayerService { if (!nativeTrackPlayer) { throw new Error( - "Native TrackPlayer module is not available. If you are using Expo, make sure you are *not* running in Expo Go and that you have rebuilt the app after installing react-native-track-player." + "Native TrackPlayer module is not available. If you are using Expo, make sure you are *not* running in Expo Go and that you have rebuilt the app after installing react-native-track-player.", ); } @@ -140,7 +287,7 @@ export class TrackPlayerService { return originalGetConstants.call(this); } catch (e) { console.warn( - "[TrackPlayerService] TurboModule getConstants error, returning empty object" + "[TrackPlayerService] TurboModule getConstants error, returning empty object", ); return {}; } @@ -148,7 +295,7 @@ export class TrackPlayerService { } catch (e) { console.warn( "[TrackPlayerService] Failed to wrap native module methods:", - e + e, ); } } @@ -160,14 +307,16 @@ export class TrackPlayerService { console.log("[TrackPlayerService] TrackPlayer state check:", state); } catch (error) { console.warn( - "[TrackPlayerService] TrackPlayer not ready, attempting setup..." + "[TrackPlayerService] TrackPlayer not ready, attempting setup...", ); await this.setupPlayer(); } } async setupPlayer() { - if (this.isSetup) return; + if (this.isSetup) { + return; + } try { console.log("[TrackPlayerService] Setting up TrackPlayer..."); @@ -176,8 +325,37 @@ export class TrackPlayerService { if (!TrackPlayer) { console.error("[TrackPlayerService] TrackPlayer is null!"); throw new Error( - "TrackPlayer is not available - make sure react-native-track-player is properly installed" + "TrackPlayer is not available - make sure react-native-track-player is properly installed", + ); + } + + // Check if Capability constants are available + if (!Capability) { + console.warn( + "[TrackPlayerService] Capability constants are null, using string fallbacks", ); + } else { + console.log( + "[TrackPlayerService] Capability constants available:", + Object.keys(Capability), + ); + // Test individual capabilities to ensure they're not null + try { + const testCapabilities = [ + getSafeCapability("Play"), + getSafeCapability("Pause"), + getSafeCapability("SkipToNext"), + getSafeCapability("SkipToPrevious"), + ]; + console.log( + "[TrackPlayerService] All capability fallbacks working correctly", + ); + } catch (error) { + console.error( + "[TrackPlayerService] Error testing capability fallbacks:", + error, + ); + } } // Check if native module behind TrackPlayer is available @@ -187,10 +365,10 @@ export class TrackPlayerService { if (!nativeTrackPlayer) { console.error( - "[TrackPlayerService] Native TrackPlayer module is null - this usually means the native module is not linked or you are running in an environment (like Expo Go or web) that does not support react-native-track-player." + "[TrackPlayerService] Native TrackPlayer module is null - this usually means the native module is not linked or you are running in an environment (like Expo Go or web) that does not support react-native-track-player.", ); throw new Error( - "Native TrackPlayer module is not available. Rebuild the app after installing react-native-track-player and avoid running in Expo Go." + "Native TrackPlayer module is not available. Rebuild the app after installing react-native-track-player and avoid running in Expo Go.", ); } @@ -201,23 +379,23 @@ export class TrackPlayerService { const constants = nativeTrackPlayer.getConstants(); console.log( "[TrackPlayerService] TrackPlayer constants available:", - !!constants + !!constants, ); } } catch (turboError) { console.warn( - "[TrackPlayerService] TurboModule compatibility issue detected, continuing with setup..." + "[TrackPlayerService] TurboModule compatibility issue detected, continuing with setup...", ); // Continue with setup even if there are TurboModule issues } console.log( "[TrackPlayerService] TrackPlayer object type:", - typeof TrackPlayer + typeof TrackPlayer, ); console.log( "[TrackPlayerService] TrackPlayer methods:", - Object.keys(TrackPlayer) + Object.keys(TrackPlayer), ); // Add a small delay to ensure native module is ready during development reload @@ -233,9 +411,68 @@ export class TrackPlayerService { ], }); console.log( - "[TrackPlayerService] TrackPlayer setup completed successfully" + "[TrackPlayerService] TrackPlayer setup completed successfully", ); + // Build capabilities array safely + const capabilities = []; + const compactCapabilities = []; + const notificationCapabilities = []; + + // Only use actual Capability constants if they're available + if (TrackPlayerCapability) { + try { + capabilities.push( + TrackPlayerCapability.Play, + TrackPlayerCapability.Pause, + TrackPlayerCapability.SkipToNext, + TrackPlayerCapability.SkipToPrevious, + TrackPlayerCapability.Stop, + TrackPlayerCapability.SeekTo, + ); + compactCapabilities.push( + TrackPlayerCapability.Play, + TrackPlayerCapability.Pause, + TrackPlayerCapability.SkipToNext, + TrackPlayerCapability.SkipToPrevious, + ); + notificationCapabilities.push( + TrackPlayerCapability.Play, + TrackPlayerCapability.Pause, + TrackPlayerCapability.SkipToNext, + TrackPlayerCapability.SkipToPrevious, + ); + } catch (error) { + console.warn( + "[TrackPlayerService] Error accessing capability constants, using fallbacks", + ); + } + } + + // If no capabilities were added (constants are null), use string fallbacks + if (capabilities.length === 0) { + capabilities.push( + "play", + "pause", + "skipToNext", + "skipToPrevious", + "stop", + "seekTo", + ); + compactCapabilities.push( + "play", + "pause", + "skipToNext", + "skipToPrevious", + ); + notificationCapabilities.push( + "play", + "pause", + "skipToNext", + "skipToPrevious", + ); + } + await TrackPlayer.updateOptions({ android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.ContinuePlayback, @@ -244,26 +481,9 @@ export class TrackPlayerService { // This is the key for proper media session integration // The service will automatically handle media session creation progressUpdateEventInterval: 1, - capabilities: [ - Capability.Play, - Capability.Pause, - Capability.SkipToNext, - Capability.SkipToPrevious, - Capability.Stop, - Capability.SeekTo, - ], - compactCapabilities: [ - Capability.Play, - Capability.Pause, - Capability.SkipToNext, - Capability.SkipToPrevious, - ], - notificationCapabilities: [ - Capability.Play, - Capability.Pause, - Capability.SkipToNext, - Capability.SkipToPrevious, - ], + capabilities, + compactCapabilities, + notificationCapabilities, }); this.setupEventListeners(); @@ -276,41 +496,44 @@ export class TrackPlayerService { } private setupEventListeners() { - TrackPlayer.addEventListener(Event.RemotePlay, () => { + TrackPlayer.addEventListener(getSafeEvent("RemotePlay"), () => { TrackPlayer.play(); }); - TrackPlayer.addEventListener(Event.RemotePause, () => { + TrackPlayer.addEventListener(getSafeEvent("RemotePause"), () => { TrackPlayer.pause(); }); - TrackPlayer.addEventListener(Event.RemoteNext, () => { + TrackPlayer.addEventListener(getSafeEvent("RemoteNext"), () => { TrackPlayer.skipToNext(); }); - TrackPlayer.addEventListener(Event.RemotePrevious, () => { + TrackPlayer.addEventListener(getSafeEvent("RemotePrevious"), () => { TrackPlayer.skipToPrevious(); }); - TrackPlayer.addEventListener(Event.RemoteStop, () => { + TrackPlayer.addEventListener(getSafeEvent("RemoteStop"), () => { TrackPlayer.stop(); }); - TrackPlayer.addEventListener(Event.RemoteSeek, (event) => { + TrackPlayer.addEventListener(getSafeEvent("RemoteSeek"), (event: any) => { TrackPlayer.seekTo(event.position); }); - TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { + TrackPlayer.addEventListener(getSafeEvent("PlaybackQueueEnded"), () => { console.log("[TrackPlayerService] Playback queue ended"); }); - TrackPlayer.addEventListener(Event.PlaybackTrackChanged, (event) => { - console.log( - "[TrackPlayerService] Track changed to index:", - event.nextTrack - ); - this.currentTrackIndex = event.nextTrack || 0; - }); + TrackPlayer.addEventListener( + getSafeEvent("PlaybackTrackChanged"), + (event: any) => { + console.log( + "[TrackPlayerService] Track changed to index:", + event.nextTrack, + ); + this.currentTrackIndex = event.nextTrack || 0; + }, + ); } convertTrackToTrackPlayer(track: Track, index: number) { @@ -338,18 +561,18 @@ export class TrackPlayerService { try { console.log( "[TrackPlayerService] addTracks called, isSetup:", - this.isSetup + this.isSetup, ); // Ensure player is initialized and ready before adding tracks await this.ensureTrackPlayerReady(); console.log( - "[TrackPlayerService] Player setup complete, proceeding with addTracks" + "[TrackPlayerService] Player setup complete, proceeding with addTracks", ); const trackPlayerTracks = tracks.map((track, index) => - this.convertTrackToTrackPlayer(track, index) + this.convertTrackToTrackPlayer(track, index), ); console.log("[TrackPlayerService] About to call TrackPlayer.reset()"); @@ -357,12 +580,12 @@ export class TrackPlayerService { try { await TrackPlayer.reset(); console.log( - "[TrackPlayerService] TrackPlayer.reset() completed successfully" + "[TrackPlayerService] TrackPlayer.reset() completed successfully", ); } catch (resetError) { console.error( "[TrackPlayerService] TrackPlayer.reset() failed:", - resetError + resetError, ); console.error("[TrackPlayerService] TrackPlayer object:", TrackPlayer); throw resetError; @@ -380,7 +603,7 @@ export class TrackPlayerService { "[TrackPlayerService] Added", tracks.length, "tracks starting at index", - startIndex + startIndex, ); } catch (error) { console.error("[TrackPlayerService] Failed to add tracks:", error); @@ -436,7 +659,7 @@ export class TrackPlayerService { await TrackPlayer.skipToNext(); this.currentTrackIndex = Math.min( this.currentTrackIndex + 1, - this.playlist.length - 1 + this.playlist.length - 1, ); console.log("[TrackPlayerService] Skipped to next track"); } catch (error) { @@ -504,7 +727,7 @@ export class TrackPlayerService { } catch (error) { console.error( "[TrackPlayerService] Failed to get playback state:", - error + error, ); return { state: State.None }; } @@ -542,7 +765,7 @@ export class TrackPlayerService { async setRepeatMode(mode: "off" | "one" | "all") { try { - let repeatMode: RepeatMode; + let repeatMode: any; switch (mode) { case "one": repeatMode = RepeatMode.Track; @@ -604,12 +827,12 @@ export class TrackPlayerService { console.log( "[TrackPlayerService] Updated current track with new URL:", - newAudioUrl + newAudioUrl, ); } catch (error) { console.error( "[TrackPlayerService] Failed to update current track:", - error + error, ); throw error; } diff --git a/services/playbackService.ts b/services/playbackService.ts index 020e2f0..697a710 100644 --- a/services/playbackService.ts +++ b/services/playbackService.ts @@ -1,4 +1,4 @@ -import TrackPlayer, { Event } from "react-native-track-player"; +import TrackPlayer, { Event } from "../utils/safeTrackPlayer"; /** * Playback service for React Native Track Player diff --git a/utils/localization.ts b/utils/localization.ts index c88f62e..e0a0deb 100644 --- a/utils/localization.ts +++ b/utils/localization.ts @@ -1,5 +1,31 @@ -const translations: Record = {} +import enTranslations from "../locales/en.json" with { type: "json" }; + +const translations: Record = {}; + +// Initialize translations with English +function initializeTranslations() { + const flattenTranslations = ( + obj: any, + prefix = "", + ): Record => { + let result: Record = {}; + for (const key in obj) { + const newKey = prefix ? `${prefix}.${key}` : key; + if (typeof obj[key] === "object" && obj[key] !== null) { + result = { ...result, ...flattenTranslations(obj[key], newKey) }; + } else { + result[newKey] = obj[key]; + } + } + return result; + }; + + const flattened = flattenTranslations(enTranslations); + Object.assign(translations, flattened); +} + +initializeTranslations(); export function t(key: string): string { - return translations[key] ?? key + return translations[key] ?? key; } diff --git a/utils/safeTrackPlayer.ts b/utils/safeTrackPlayer.ts new file mode 100644 index 0000000..8827334 --- /dev/null +++ b/utils/safeTrackPlayer.ts @@ -0,0 +1,105 @@ +import { NativeModules } from "react-native"; + +// --------------------------------------------------------------------------- +// 1. Detect if the native module is linked ----------------------------------- +// --------------------------------------------------------------------------- +const hasNativeModule = Boolean( + (NativeModules as any).TrackPlayerModule || + (NativeModules as any).TrackPlayer, +); + +// --------------------------------------------------------------------------- +// 2. No-op / safe fallback implementations ----------------------------------- +// --------------------------------------------------------------------------- +const noop = () => Promise.resolve(); +const noopSync = () => undefined; + +const safePlayer = { + // Lifecycle + setupPlayer: noop, + destroy: noop, + reset: noop, + + // Playback control + play: noop, + pause: noop, + stop: noop, + seekTo: noop, + setVolume: noop, + setRate: noop, + + // Queue management + add: noop, + remove: noop, + skip: noop, + skipToNext: noop, + skipToPrevious: noop, + removeUpcomingTracks: noop, + setQueue: noop, + updateMetadataForTrack: noop, + clearNowPlayingMetadata: noopSync, + updateNowPlayingMetadata: noopSync, + + // Getters (return safe defaults) + getPosition: () => Promise.resolve(0), + getDuration: () => Promise.resolve(0), + getBufferedPosition: () => Promise.resolve(0), + getPlaybackState: () => Promise.resolve({ state: "idle" }), + getActiveTrack: () => Promise.resolve(null), + getQueue: () => Promise.resolve([]), + getCurrentTrack: () => Promise.resolve(null), + getVolume: () => Promise.resolve(1), + getRate: () => Promise.resolve(1), + getPlayWhenReady: () => Promise.resolve(false), + isServiceRunning: () => false, + + // Events (return an empty unsubscribe function) + addEventListener: () => ({ remove: noopSync }) as any, + + // Registration (safe no-op) + registerPlaybackService: noopSync, +} as const; + +// --------------------------------------------------------------------------- +// 3. Export either the real module or the safe stub ------------------------ +// --------------------------------------------------------------------------- +export default hasNativeModule + ? require("react-native-track-player").default + : safePlayer; + +// Also re-export the named exports so `import { Event, Capability } ...` keeps working +export const Event = hasNativeModule + ? require("react-native-track-player").Event + : ({} as any); + +export const Capability = hasNativeModule + ? require("react-native-track-player").Capability + : ({} as any); + +export const State = hasNativeModule + ? require("react-native-track-player").State + : ({} as any); + +export const RepeatMode = hasNativeModule + ? require("react-native-track-player").RepeatMode + : ({} as any); + +export const AppKilledPlaybackBehavior = hasNativeModule + ? require("react-native-track-player").AppKilledPlaybackBehavior + : ({} as any); + +export const IOSCategory = hasNativeModule + ? require("react-native-track-player").IOSCategory + : ({} as any); + +export const IOSCategoryMode = hasNativeModule + ? require("react-native-track-player").IOSCategoryMode + : ({} as any); + +export const IOSCategoryOptions = hasNativeModule + ? require("react-native-track-player").IOSCategoryOptions + : ({} as any); + +export const PitchAlgorithm = hasNativeModule + ? require("react-native-track-player").PitchAlgorithm + : ({} as any); From eb508bb22b1022f4fe00fcc5f08fe26b80b635d6 Mon Sep 17 00:00:00 2001 From: Erfan Date: Sun, 8 Feb 2026 12:20:54 +0330 Subject: [PATCH 6/7] feat: refactor audio streaming and disable JioSaavn to focus on YouTube - Replace YouTube Omada exclusive handling with multi-strategy approach including dynamic Invidious instances - Disable JioSaavn search, album, and song details to focus on YouTube functionality - Remove console logs from MiniPlayer and image color extraction for cleaner output - Update PlayerScreen to use simplified getAudioStreamUrl parameters - Fix trailing commas and quote consistency in various components - Add test scripts for dynamic instances and Invidious testing - Update App.tsx to use initializeDynamicInstances instead of updateInvidiousInstancesFromUma - Temporarily disable playlist loading on HomeScreen for performance - Improve FullPlayerModal to use PlayerContext for real-time position updates instead of intervals - Fix ArtistScreen to properly display verified icon and handle monthly listeners --- .github/workflows/development-build.yml | 8 +- App.tsx | 4 +- components/FullPlayerModal.tsx | 51 +- components/MiniPlayer.tsx | 8 +- components/StreamItem.tsx | 10 +- components/core/api.ts | 43 +- components/screens/AlbumPlaylistScreen.tsx | 8 +- components/screens/ArtistScreen.tsx | 68 +- components/screens/HomeScreen.tsx | 62 +- components/screens/PlayerScreen.tsx | 8 +- components/screens/SearchScreen.tsx | 19 +- components/screens/SettingsScreen.tsx | 4 +- contexts/PlayerContext.tsx | 363 +++++---- locales/fa.json | 2 +- modules/audioStreaming.ts | 396 +++++++-- modules/searchAPI.ts | 35 +- modules/store.ts | 2 +- package-lock.json | 2 - services/TrackPlayerService.ts | 895 ++++++++++++--------- test_instances.js | 5 + test_invidious.js | 50 ++ utils/imageColors.ts | 25 +- 22 files changed, 1256 insertions(+), 812 deletions(-) create mode 100644 test_instances.js create mode 100644 test_invidious.js diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml index 4e3c7fe..0068f42 100644 --- a/.github/workflows/development-build.yml +++ b/.github/workflows/development-build.yml @@ -87,12 +87,12 @@ jobs: - name: 📱 Build Debug APK (development profile) run: | export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - + echo "Building debug APK for development branch using EAS cloud build..." - + # Build and wait for completion eas build --platform android --profile debug-apk --non-interactive --wait - + echo "Development debug APK build completed! 🎉" echo "" echo "The development APK has been built successfully!" @@ -118,4 +118,4 @@ jobs: with: name: app-dev-apk path: ./**/*.apk - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/App.tsx b/App.tsx index e53d9dc..9aaa8f7 100644 --- a/App.tsx +++ b/App.tsx @@ -24,7 +24,7 @@ import { MaterialIcons } from "@expo/vector-icons"; import { PlayerProvider } from "./contexts/PlayerContext"; // API -import { updateInvidiousInstancesFromUma } from "./components/core/api"; +import { initializeDynamicInstances } from "./components/core/api"; // Components import { MiniPlayer } from "./components/MiniPlayer"; @@ -385,7 +385,7 @@ export default function App() { useEffect(() => { const fetchInstances = async () => { console.log("[App] Fetching Invidious instances from Uma repository..."); - await updateInvidiousInstancesFromUma(); + await initializeDynamicInstances(); console.log("[App] Invidious instances update complete"); }; diff --git a/components/FullPlayerModal.tsx b/components/FullPlayerModal.tsx index cda0949..66b282e 100644 --- a/components/FullPlayerModal.tsx +++ b/components/FullPlayerModal.tsx @@ -545,10 +545,10 @@ export const FullPlayerModal: React.FC = ({ setRepeatMode, isShuffled, toggleShuffle, + position, + duration, } = usePlayer(); - const [currentPosition, setCurrentPosition] = useState(0); - const [duration, setDuration] = useState(0); const [cacheInfo, setCacheInfo] = useState<{ percentage: number; fileSize: number; @@ -812,36 +812,8 @@ export const FullPlayerModal: React.FC = ({ } }, [cacheProgress, currentTrack?.id]); - // Reset position when track changes - useEffect(() => { - setCurrentPosition(0); - setDuration(0); - }, [currentTrack?.id]); - - // Track position and duration - useEffect(() => { - if (!currentTrack?.audioUrl) { - return; - } - - const updatePosition = async () => { - try { - const position = await TrackPlayer.getPosition(); - const duration = await TrackPlayer.getDuration(); - setCurrentPosition(position * 1000); // Convert to milliseconds - setDuration(duration * 1000); // Convert to milliseconds - } catch (error) { - if (!error?.toString().includes("Player does not exist")) { - // Silently ignore player not existing errors - } - } - }; - - const interval = setInterval(updatePosition, 1000); - updatePosition(); - - return () => clearInterval(interval); - }, [currentTrack?.id]); // Reset when track changes + // Position and duration are now managed by PlayerContext via PlaybackProgressUpdated events + // This provides real-time updates instead of 1-second intervals // Cache info update effect useEffect(() => { @@ -869,12 +841,13 @@ export const FullPlayerModal: React.FC = ({ const handleSeek = async (value: number) => { try { if (currentTrack?.audioUrl) { - await seekTo(value); + // Convert milliseconds to seconds for seekTo + await seekTo(value / 1000); } - setCurrentPosition(value); + // Position is now managed by PlayerContext via PlaybackProgressUpdated events } catch (error) { // Silently ignore seek errors - setCurrentPosition(value); + // Position is now managed by PlayerContext via PlaybackProgressUpdated events } }; @@ -1073,8 +1046,8 @@ export const FullPlayerModal: React.FC = ({ = ({ /> - {formatTime(currentPosition)} - {formatTime(duration)} + {formatTime(position * 1000)} + {formatTime(duration * 1000)} diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index e2c488d..ca28761 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -154,13 +154,7 @@ export const MiniPlayer: React.FC = ({ const displayTheme = colorTheme; // Debug: Log the current color theme - console.log("[MiniPlayer] Current theme:", { - isGradient: displayTheme.isGradient, - hasGradient: !!displayTheme.gradient, - gradient: displayTheme.gradient, - background: displayTheme.background, - primary: displayTheme.primary, - }); + return ( { if ( !authorName || @@ -238,7 +238,7 @@ function StreamItem(props: StreamItemProps) { } return authorName.replace(" - Topic", ""); }, - [] + [], ); const formatSubMeta = useCallback( @@ -250,7 +250,7 @@ function StreamItem(props: StreamItemProps) { videoCount?: string, isAlbum?: boolean, searchFilter?: string, - searchSource?: string + searchSource?: string, ) => { const parts = []; @@ -277,7 +277,7 @@ function StreamItem(props: StreamItemProps) { } return parts.join(" • "); }, - [] + [], ); return ( @@ -346,7 +346,7 @@ function StreamItem(props: StreamItemProps) { videoCount, isAlbum, searchFilter, - searchSource + searchSource, )} {/* Show blue badge for albums/playlists with video count */} {(isAlbum || type === "playlist") && videoCount && ( diff --git a/components/core/api.ts b/components/core/api.ts index be6c4af..d2a8179 100644 --- a/components/core/api.ts +++ b/components/core/api.ts @@ -5,19 +5,13 @@ export const API = { // Invidious instances for YouTube content (will be updated dynamically) invidious: [ - "https://inv-veltrix.zeabur.app/api/v1", - "https://inv-veltrix-2.zeabur.app/api/v1", - "https://inv-veltrix-3.zeabur.app/api/v1", - "https://yt.omada.cafe/api/v1", - "https://invidious.f5.si/api/v1", + "https://inv-veltrix.zeabur.app", + "https://inv-veltrix-2.zeabur.app", + "https://inv-veltrix-3.zeabur.app", + "https://yt.omada.cafe", + "https://invidious.f5.si", ], - // HLS streaming endpoints - hls: ["https://api.piped.private.coffee"], - - // Hyperpipe API for additional functionality - hyperpipe: ["https://hyperpipeapi.onrender.com"], - // JioSaavn API endpoints jiosaavn: { base: "https://streamifyjiosaavn.vercel.app/api", @@ -298,6 +292,33 @@ export async function fetchStreamFromInvidiousWithFallback(id: string) { throw new Error(`All Invidious instances failed: ${errors.join(", ")}`); } +// Initialize dynamic instances on app startup +export async function initializeDynamicInstances(): Promise { + try { + console.log("[API] Fetching dynamic Invidious instances from Uma..."); + const umaInstances = await fetchUma(); + + if (umaInstances.length > 0) { + // Add /api/v1 suffix to instances if not present + const formattedInstances = umaInstances.map((instance) => { + if (!instance.includes("/api/v1")) { + return `${instance}/api/v1`; + } + return instance; + }) as InvidiousInstance[]; + + updateInvidiousInstances(formattedInstances); + console.log( + `[API] Updated with ${formattedInstances.length} dynamic instances from Uma` + ); + } else { + console.log("[API] No dynamic instances fetched, using defaults"); + } + } catch (error) { + console.error("[API] Error initializing dynamic instances:", error); + } +} + export async function getStreamData( id: string, prefer: "piped" | "invidious" = "piped" diff --git a/components/screens/AlbumPlaylistScreen.tsx b/components/screens/AlbumPlaylistScreen.tsx index 6aedaa0..12dd539 100644 --- a/components/screens/AlbumPlaylistScreen.tsx +++ b/components/screens/AlbumPlaylistScreen.tsx @@ -293,12 +293,8 @@ export const AlbumPlaylistScreen: React.FC = ({ setIsLoading(true); if (source === "jiosaavn") { - console.log("[AlbumPlaylistScreen] Fetching JioSaavn album details"); - const { searchAPI } = await import("../../modules/searchAPI"); - const albumDetails = await searchAPI.getJioSaavnAlbumDetails( - albumId, - albumName - ); + console.log("[AlbumPlaylistScreen] JioSaavn disabled - using fallback"); + const albumDetails = null; // Disable JioSaavn album details if ( albumDetails && diff --git a/components/screens/ArtistScreen.tsx b/components/screens/ArtistScreen.tsx index 259b82c..633a3ca 100644 --- a/components/screens/ArtistScreen.tsx +++ b/components/screens/ArtistScreen.tsx @@ -368,7 +368,9 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { // Function to calculate font size based on artist name length const calculateFontSize = useCallback((name: string): number => { - if (!name) return 64; + if (!name) { + return 64; + } const baseFontSize = 64; const minFontSize = 32; @@ -396,14 +398,14 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { `${API.piped[0]}/channel/${channelId}`, {}, 3, - 1000 + 1000, ); console.log("Channel tabs data for albums:", channelData.tabs); // Look for albums tab data const albumsTab = channelData.tabs?.find( - (tab: any) => tab.name === "albums" + (tab: any) => tab.name === "albums", ); if (albumsTab && albumsTab.data) { try { @@ -414,7 +416,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { `${API.piped[0]}/channels/tabs?data=${encodedData}`, {}, 3, - 1000 + 1000, ); console.log("Albums data fetched successfully:", albumsData); @@ -435,7 +437,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } catch (apiError) { console.log( "Failed to fetch albums from tabs endpoint, using fallback:", - apiError + apiError, ); } } @@ -456,27 +458,27 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { `${API.piped[0]}/channel/${channelId}`, {}, 3, - 1000 + 1000, ); console.log("Channel tabs data:", channelData.tabs); // Look for playlists tab data const playlistsTab = channelData.tabs?.find( - (tab: any) => tab.name === "playlists" + (tab: any) => tab.name === "playlists", ); if (playlistsTab && playlistsTab.data) { try { // Use the correct GET format for the tabs endpoint const playlistsTabData = JSON.parse(playlistsTab.data); const encodedData = encodeURIComponent( - JSON.stringify(playlistsTabData) + JSON.stringify(playlistsTabData), ); const playlistsData = await fetchWithRetry( `${API.piped[0]}/channels/tabs?data=${encodedData}`, {}, 3, - 1000 + 1000, ); console.log("Playlists data fetched successfully:", playlistsData); @@ -495,7 +497,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } console.log( - `[ArtistScreen] Extracted playlist ID: ${playlistId} from URL: ${playlist.url}` + `[ArtistScreen] Extracted playlist ID: ${playlistId} from URL: ${playlist.url}`, ); return { @@ -520,7 +522,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { } catch (apiError) { console.log( "Failed to fetch playlists from tabs endpoint, using fallback:", - apiError + apiError, ); } } @@ -556,7 +558,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { `${API.piped[0]}/channel/${artistId}`, {}, 3, - 1000 + 1000, ); console.log("YouTube channel API response:", channelData); @@ -582,7 +584,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { playCount: video.views || 0, source: "youtube", _isJioSaavn: false, - }) + }), ); // Process channel tabs data for albums and playlists @@ -610,7 +612,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { getJioSaavnArtistEndpoint(artistId), {}, 3, - 1000 + 1000, ); console.log("Artist info API response:", artistInfo); @@ -627,7 +629,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { getJioSaavnArtistSongsEndpoint(artistId, 0), {}, 3, - 1000 + 1000, ); console.log("Songs API response:", songsData); } catch (e) { @@ -642,7 +644,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { getJioSaavnArtistAlbumsEndpoint(artistId, 0), {}, 3, - 1000 + 1000, ); console.log("Albums API response:", albumsData); } catch (e) { @@ -781,7 +783,7 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { source: s.source || "jiosaavn", _isJioSaavn: s._isJioSaavn || false, })), - index + index, ); }; @@ -922,22 +924,22 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { {artistData.name} {artistData.verified && ( - - - + )} - {artistData.monthlyListeners && ( - - {formatMonthlyListeners(artistData.monthlyListeners)}{" "} - {t("screens.artist.monthly_listeners")} - - )} + {artistData.monthlyListeners !== undefined && + artistData.monthlyListeners !== null && ( + + {formatMonthlyListeners(artistData.monthlyListeners)}{" "} + {t("screens.artist.monthly_listeners")} + + )} @@ -1036,7 +1038,11 @@ const ArtistScreen: React.FC = ({ navigation, route }) => { > {album.title} - {album.videoCount || album.year} + + {album.videoCount + ? `${album.videoCount} videos` + : album.year} + ))} diff --git a/components/screens/HomeScreen.tsx b/components/screens/HomeScreen.tsx index 7bc3cbd..23550f4 100644 --- a/components/screens/HomeScreen.tsx +++ b/components/screens/HomeScreen.tsx @@ -260,8 +260,11 @@ export default function HomeScreen({ navigation }: any) { const [loadingFeatured, setLoadingFeatured] = useState(true); // Filter out Hindi/Indian playlists - // Fetch playlist data for a specific category + // Fetch playlist data for a specific category - COMMENTED OUT const fetchCategoryPlaylists = async (category: string) => { + // Temporarily disabled - no playlist loading + return; + /* try { const data = await fetchWithRetry( CATEGORY_APIS[category as keyof typeof CATEGORY_APIS], @@ -277,10 +280,15 @@ export default function HomeScreen({ navigation }: any) { } catch (error) { console.error(`Failed to fetch ${category} playlists:`, error); } + */ }; - // Fetch featured playlist + // Fetch featured playlist - COMMENTED OUT const fetchFeaturedPlaylists = async () => { + // Temporarily disabled - no featured playlists loading + setLoadingFeatured(false); + return; + /* try { setLoadingFeatured(true); @@ -293,10 +301,7 @@ export default function HomeScreen({ navigation }: any) { 3, 1000 ); - console.log( - `[DEBUG] Playlist ${playlistId} response:`, - JSON.stringify(data, null, 2) - ); + if (data.success && data.data) { return data.data; } @@ -318,11 +323,7 @@ export default function HomeScreen({ navigation }: any) { (playlist) => playlist !== null ); - console.log(`[DEBUG] Valid playlists count: ${validPlaylists.length}`); - console.log( - `[DEBUG] First playlist structure:`, - JSON.stringify(validPlaylists[0], null, 2) - ); + // Transform playlist data to match expected interface const transformedPlaylists = validPlaylists.map((playlist) => { @@ -368,7 +369,7 @@ export default function HomeScreen({ navigation }: any) { return playlistData; } catch (error) { - console.error(`[DEBUG] Error transforming playlist:`, error); + // Return a default playlist structure if transformation fails return { id: "error", @@ -388,10 +389,7 @@ export default function HomeScreen({ navigation }: any) { } }); - console.log( - `[DEBUG] Transformed first playlist:`, - JSON.stringify(transformedPlaylists[0], null, 2) - ); + setFeaturedPlaylists(transformedPlaylists); } catch (error) { @@ -399,10 +397,14 @@ export default function HomeScreen({ navigation }: any) { } finally { setLoadingFeatured(false); } + */ }; - // Toggle category selection + // Toggle category selection - COMMENTED OUT const toggleCategory = (category: string) => { + // Temporarily disabled - no category functionality + return; + /* setSelectedCategories((prev) => { // Handle "All" button logic if (category === "all") { @@ -428,10 +430,14 @@ export default function HomeScreen({ navigation }: any) { // If no categories selected, default to "All" return newCategories.length === 0 ? ["all"] : newCategories; }); + */ }; - // Load initial data + // Load initial data - COMMENTED OUT useEffect(() => { + // Temporarily disabled - no playlist loading on homescreen + return; + /* // Load featured playlists first (now in parallel for faster loading) fetchFeaturedPlaylists(); @@ -452,6 +458,7 @@ export default function HomeScreen({ navigation }: any) { }, 200); // Small delay to prioritize featured playlists return () => clearTimeout(categoryLoadTimeout); + */ }, []); const handlePlayTrack = (track: any) => { @@ -479,16 +486,11 @@ export default function HomeScreen({ navigation }: any) { const getPlaylistImageSource = (playlist: Playlist) => { try { - console.log( - `[DEBUG] Playlist ${playlist.id} image data:`, - JSON.stringify(playlist.image, null, 2) - ); - // Ensure playlist.image is an array const imageArray = Array.isArray(playlist.image) ? playlist.image : []; const highQualityImage = imageArray.find( - (img) => img && img.quality === "500x500" + (img) => img && img.quality === "500x500", ); const imageUrl = highQualityImage?.url || imageArray[0]?.url; @@ -499,10 +501,6 @@ export default function HomeScreen({ navigation }: any) { return require("../../assets/StreamifyLogo.png"); } } catch (error) { - console.error( - `[DEBUG] Error in getPlaylistImageSource for playlist ${playlist.id}:`, - error - ); return require("../../assets/StreamifyLogo.png"); } }; @@ -554,7 +552,8 @@ export default function HomeScreen({ navigation }: any) { - {/* Featured Playlists */} + {/* Featured Playlists - COMMENTED OUT */} + {/*
Featured Playlists @@ -578,7 +577,9 @@ export default function HomeScreen({ navigation }: any) { )}
- {/* Selected Category Playlists */} + */} + {/* Selected Category Playlists - COMMENTED OUT */} + {/* {(selectedCategories.includes("all") ? Object.keys(CATEGORY_APIS) : selectedCategories @@ -617,6 +618,7 @@ export default function HomeScreen({ navigation }: any) { ); })} + */} ); diff --git a/components/screens/PlayerScreen.tsx b/components/screens/PlayerScreen.tsx index ed508bb..289d59d 100644 --- a/components/screens/PlayerScreen.tsx +++ b/components/screens/PlayerScreen.tsx @@ -148,13 +148,7 @@ async function getAudioUrlWithFallback( trackArtist?: string, ): Promise { try { - return await getAudioStreamUrl( - videoId, - onStatus, - source, - trackTitle, - trackArtist, - ); + return await getAudioStreamUrl(videoId, onStatus, source); } catch (error) { console.error("[Player] All audio extraction methods failed:", error); throw new Error( diff --git a/components/screens/SearchScreen.tsx b/components/screens/SearchScreen.tsx index 5627aaf..7945ef8 100644 --- a/components/screens/SearchScreen.tsx +++ b/components/screens/SearchScreen.tsx @@ -614,13 +614,9 @@ export default function SearchScreen({ navigation }: any) { 20 ); } else if (selectedSource === "jiosaavn") { - // JioSaavn Search - results = await searchAPI.searchWithJioSaavn( - queryToUse, - selectedFilter, - paginationRef.current.page, - 20 - ); + // JioSaavn Search - disabled + console.log("[\SearchScreen] JioSaavn search disabled"); + results = []; } else if (selectedSource === "spotify") { // Placeholder for Spotify console.log(t("search.spotify_not_implemented")); @@ -1151,12 +1147,11 @@ export default function SearchScreen({ navigation }: any) { } try { - // Fetch album details to get all songs - const { searchAPI } = await import("../../modules/searchAPI"); - const albumDetails = await searchAPI.getJioSaavnAlbumDetails( - item.albumId, - item.albumName + // Fetch album details to get all songs - JioSaavn disabled + console.log( + "[SearchScreen] JioSaavn album details disabled - using fallback" ); + const albumDetails = null; // Disable JioSaavn album details if ( albumDetails && diff --git a/components/screens/SettingsScreen.tsx b/components/screens/SettingsScreen.tsx index bd06df5..c5a4fe1 100644 --- a/components/screens/SettingsScreen.tsx +++ b/components/screens/SettingsScreen.tsx @@ -61,8 +61,8 @@ const SettingLeft = styled.View` flex: 1; padding-left: ${(props) => props.hasIcon - ? '8px' - : '0px'}; /* 24px (icon width) + 12px (margin) = 36px */ + ? "8px" + : "0px"}; /* 24px (icon width) + 12px (margin) = 36px */ `; const SettingRight = styled.View` diff --git a/contexts/PlayerContext.tsx b/contexts/PlayerContext.tsx index 2278aa0..f305a7e 100644 --- a/contexts/PlayerContext.tsx +++ b/contexts/PlayerContext.tsx @@ -42,11 +42,11 @@ interface PlayerContextType { isLoading: boolean; showFullPlayer: boolean; repeatMode: "off" | "one" | "all"; - isShuffled: boolean; isInPlaylistContext: boolean; - colorTheme: ExtendedColorTheme; + isShuffled: boolean; likedSongs: Track[]; previouslyPlayedSongs: Track[]; + colorTheme: ExtendedColorTheme; cacheProgress: { trackId: string; percentage: number; @@ -54,12 +54,15 @@ interface PlayerContextType { } | null; isTransitioning: boolean; streamRetryCount: number; + hasStreamFailed: boolean; + position: number; + duration: number; // Actions playTrack: ( track: Track, playlist?: Track[], - index?: number, + index?: number ) => Promise; playPause: () => Promise; nextTrack: () => Promise; @@ -103,7 +106,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const [isShuffled, setIsShuffled] = useState(false); const [likedSongs, setLikedSongs] = useState([]); const [previouslyPlayedSongs, setPreviouslyPlayedSongs] = useState( - [], + [] ); const [colorTheme, setColorTheme] = useState({ primary: "#a3e635", @@ -120,10 +123,13 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } | null>(null); const [isTransitioning, setIsTransitioning] = useState(false); const [streamRetryCount, setStreamRetryCount] = useState(0); + const [hasStreamFailed, setHasStreamFailed] = useState(false); + const [position, setPosition] = useState(0); + const [duration, setDuration] = useState(0); const originalPlaylistRef = useRef([]); const currentPlaylistContextRef = useRef([]); const streamCheckRef = useRef<{ position: number; time: number } | null>( - null, + null ); const audioManager = useRef(new AudioStreamManager()).current; @@ -152,12 +158,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Ensure the service is properly initialized before any operations await trackPlayerService.setupPlayer(); console.log( - "[PlayerContext] TrackPlayer service initialized successfully", + "[PlayerContext] TrackPlayer service initialized successfully" ); } catch (error) { console.error( "[PlayerContext] Failed to initialize TrackPlayer service:", - error, + error ); } }; @@ -175,7 +181,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Cache all liked songs that aren't already cached if (savedLikedSongs.length > 0) { console.log( - `[PlayerContext] Found ${savedLikedSongs.length} liked songs, starting background caching...`, + `[PlayerContext] Found ${savedLikedSongs.length} liked songs, starting background caching...` ); cacheAllLikedSongs(savedLikedSongs); } @@ -194,7 +200,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (Platform.OS === "android") { if (nextAppState === "background" && isPlaying && currentTrack) { console.log( - "[PlayerContext] App going to background, continuing playback", + "[PlayerContext] App going to background, continuing playback" ); // Track Player handles background playback automatically } else if (nextAppState === "active") { @@ -206,7 +212,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const subscription = AppState.addEventListener( "change", - handleAppStateChange, + handleAppStateChange ); return () => { subscription.remove(); @@ -231,7 +237,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { if (cacheProgress && currentTrack?.id === cacheProgress.trackId) { console.log( - `[PlayerContext] Cache progress updated: ${cacheProgress.percentage}%`, + `[PlayerContext] Cache progress updated: ${cacheProgress.percentage}%` ); // Force a cache info refresh when cacheProgress changes const refreshCacheInfo = async () => { @@ -245,7 +251,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ ...prev, fileSize: info.fileSize, } - : null, + : null ); } } catch (error) { @@ -285,7 +291,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Monitor stream health and refresh if needed useEffect(() => { console.log( - `[PlayerContext] Stream monitor check - isPlaying: ${isPlaying}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}`, + `[PlayerContext] Stream monitor check - isPlaying: ${isPlaying}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}` ); if (!isPlaying || !currentTrack?.audioUrl) { @@ -296,8 +302,10 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ try { const position = await trackPlayerService.getPosition(); const duration = await trackPlayerService.getDuration(); + setPosition(position); + setDuration(duration); console.log( - `[PlayerContext] Stream status - position: ${position}s, duration: ${duration}s`, + `[PlayerContext] Stream status - position: ${position}s, duration: ${duration}s` ); if (position >= 0) { @@ -322,12 +330,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (timeDiff > 5000 && positionDiff === 0) { if (currentTrack) { console.warn( - "[PlayerContext] Stream appears stuck, attempting refresh", + "[PlayerContext] Stream appears stuck, attempting refresh" ); handleStreamFailure(); } else { console.warn( - "[PlayerContext] Stream appears stuck but no current track, skipping refresh", + "[PlayerContext] Stream appears stuck but no current track, skipping refresh" ); } streamCheckRef.current = null; @@ -336,7 +344,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } } else { console.log( - `[PlayerContext] Stream not in valid state - position: ${position}`, + `[PlayerContext] Stream not in valid state - position: ${position}` ); } } catch (error) { @@ -356,7 +364,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const playTrack = useCallback( async (track: Track, playlistData: Track[] = [], index: number = 0) => { console.log( - `[PlayerContext] playTrack() called with track: ${track.title}, index: ${index}, playlist length: ${playlistData.length}, isLoading: ${isLoading}, isTransitioning: ${isTransitioning}`, + `[PlayerContext] playTrack() called with track: ${track.title}, index: ${index}, playlist length: ${playlistData.length}, isLoading: ${isLoading}, isTransitioning: ${isTransitioning}` ); // Prevent multiple simultaneous play attempts (but allow transitions) @@ -372,6 +380,8 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Reset stream retry counter when starting a new track setStreamRetryCount(0); + // Reset stream failed flag when starting a new track + setHasStreamFailed(false); try { setIsLoading(true); @@ -384,7 +394,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } catch (error) { console.log( "[PlayerContext] Error cleaning up previous playback:", - error, + error ); } @@ -393,7 +403,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Set the track immediately so MiniPlayer can appear console.log( - `[PlayerContext] playTrack() - Setting current track: ${track.title}, index: ${index}`, + `[PlayerContext] playTrack() - Setting current track: ${track.title}, index: ${index}` ); setCurrentTrack(track); @@ -444,7 +454,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } catch (error) { console.log( "[PlayerContext] Error stopping current playback:", - error, + error ); } @@ -457,7 +467,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // If we already have a streaming URL (not a cached file), use it as original originalStreamUrl = audioUrl; console.log( - `[PlayerContext] Using provided streaming URL as original: ${originalStreamUrl}`, + `[PlayerContext] Using provided streaming URL as original: ${originalStreamUrl}` ); } @@ -466,7 +476,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (track._isSoundCloud || track.source === "soundcloud") { // SoundCloud URLs expire, so we need to get a fresh one console.log( - `[PlayerContext] Getting fresh SoundCloud URL for track: ${track.id}`, + `[PlayerContext] Getting fresh SoundCloud URL for track: ${track.id}` ); originalStreamUrl = await getAudioStreamUrl( @@ -475,80 +485,61 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log(`[PlayerContext] Streaming status: ${status}`), "soundcloud", track.title, - track.artist, + track.artist ); audioUrl = originalStreamUrl; console.log(`[PlayerContext] Got SoundCloud URL: ${audioUrl}`); } else if (track._isJioSaavn || track.source === "jiosaavn") { - // Only fetch additional details if we don't have audio URL or duration - if (!track.audioUrl || !track.duration || track.duration === 0) { - const { searchAPI } = await import("../modules/searchAPI"); - const songDetails = await searchAPI.getJioSaavnSongDetails( - track.id, - ); - - if (songDetails && songDetails.audioUrl) { - audioUrl = songDetails.audioUrl; - originalStreamUrl = audioUrl; - - if ( - songDetails.duration && - (!track.duration || track.duration === 0) - ) { - track.duration = songDetails.duration; - } - } else { - console.log( - `[PlayerContext] JioSaavn track has no audio URL, playback failed for: ${track.title}`, - ); - throw new Error( - `Unable to get audio stream for JioSaavn track: ${track.title}`, - ); - } - } else { - // Use existing audio URL if available - audioUrl = track.audioUrl; - originalStreamUrl = audioUrl; + // JioSaavn disabled - skip fetching additional details + console.log( + "[PlayerContext] JioSaavn song details disabled - using existing data" + ); + audioUrl = track.audioUrl; + originalStreamUrl = audioUrl; + if (!audioUrl) { console.log( - `[PlayerContext] Using existing JioSaavn audio URL for: ${track.title}`, + `[PlayerContext] JioSaavn track has no audio URL, playback failed for: ${track.title}` + ); + throw new Error( + `Unable to get audio stream for JioSaavn track: ${track.title}` ); } } else { // Only fetch streaming URL if we don't already have one if (!track.audioUrl) { console.log( - `[PlayerContext] Getting generic streaming URL for track: ${track.id} (source: ${track.source || "unknown"})`, + `[PlayerContext] Getting generic streaming URL for track: ${track.id} (source: ${track.source || "unknown"})` ); originalStreamUrl = await getAudioStreamUrl( track.id, (status) => console.log( - `[PlayerContext] Generic streaming status: ${status}`, + `[PlayerContext] Generic streaming status: ${status}` ), track.source || "youtube", track.title, - track.artist, + track.artist ); audioUrl = originalStreamUrl; console.log( - `[PlayerContext] Got generic streaming URL: ${audioUrl}`, + `[PlayerContext] Got generic streaming URL: ${audioUrl}` ); } else { // Use existing audio URL if available audioUrl = track.audioUrl; originalStreamUrl = audioUrl; console.log( - `[PlayerContext] Using existing audio URL for: ${track.title}`, + `[PlayerContext] Using existing audio URL for: ${track.title}` ); } } } catch (streamingError) { console.error( "[PlayerContext] Failed to get streaming URL:", - streamingError, + streamingError ); } } @@ -556,7 +547,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (!audioUrl) { // Instead of throwing an error, create a placeholder track console.warn( - "[PlayerContext] No audio URL available, creating placeholder", + "[PlayerContext] No audio URL available, creating placeholder" ); // We'll still create the sound object but with a silent/placeholder audio // This allows the UI to show the track info even if playback isn't available @@ -565,26 +556,80 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Create new track (with enhanced error handling and fallbacks) let finalAudioUrl = audioUrl; + // Check if this song is liked and cache it if so + if (finalAudioUrl && track.id) { + const isLiked = likedSongs.some((song) => song.id === track.id); + if (isLiked) { + console.log( + `[PlayerContext] Song is liked, starting caching: ${track.title}` + ); + try { + import("../modules/audioStreaming") + .then(({ continueCachingTrack }) => { + const cacheController = new AbortController(); + continueCachingTrack( + finalAudioUrl, + track.id, + cacheController, + (percentage) => { + console.log( + `[PlayerContext] Caching progress for liked song: ${percentage}%` + ); + setCacheProgress({ + trackId: track.id, + percentage: percentage, + fileSize: 0, // We don't have file size info here + }); + } + ).catch((error) => { + console.error( + `[PlayerContext] Failed to cache liked song ${track.id}:`, + error + ); + }); + }) + .catch((error) => { + console.error( + `[PlayerContext] Failed to import audioStreaming module:`, + error + ); + }); + } catch (error) { + console.error( + `[PlayerContext] Error starting cache for liked song:`, + error + ); + } + } + } + try { if (finalAudioUrl) { + // Update the current track in the playlist with the fetched audio URL + const updatedPlaylist = effectivePlaylist.map( + (playlistTrack, index) => { + if (index === effectiveIndex) { + return { ...playlistTrack, audioUrl: finalAudioUrl }; + } + return playlistTrack; + } + ); + // Ensure TrackPlayer is initialized before adding tracks console.log( - "[PlayerContext] Checking TrackPlayer initialization status...", + "[PlayerContext] Checking TrackPlayer initialization status..." ); // Add tracks to Track Player and start playback - await trackPlayerService.addTracks( - effectivePlaylist, - effectiveIndex, - ); + await trackPlayerService.addTracks(updatedPlaylist, effectiveIndex); await trackPlayerService.play(); setIsPlaying(true); console.log( - `[PlayerContext] Playback started for track: ${track.title}`, + `[PlayerContext] Playback started for track: ${track.title}` ); } else { console.warn( - `[PlayerContext] No audio URL available for track: ${track.title}`, + `[PlayerContext] No audio URL available for track: ${track.title}` ); setIsPlaying(false); } @@ -599,7 +644,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } catch (playbackError) { console.error( "[PlayerContext] Critical error in playback setup:", - playbackError, + playbackError ); // Even if everything fails, ensure UI remains functional @@ -651,7 +696,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ position > 1 ) { console.error( - `[PlayerContext] CONFIRMED: ${isYouTubeStream ? "YouTube" : "SoundCloud"} audio cutout at ${position}s - position stuck despite isPlaying=true (threshold: ${threshold}, initialBuffer: ${isInitialBufferPhase})`, + `[PlayerContext] CONFIRMED: ${isYouTubeStream ? "YouTube" : "SoundCloud"} audio cutout at ${position}s - position stuck despite isPlaying=true (threshold: ${threshold}, initialBuffer: ${isInitialBufferPhase})` ); handleStreamFailure(); positionStuckCounter = 0; @@ -660,7 +705,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ (isTransitioning || isInitialBufferPhase || position <= 1) ) { console.log( - "[PlayerContext] Skipping stream failure detection during transition or initial buffer", + "[PlayerContext] Skipping stream failure detection during transition or initial buffer" ); positionStuckCounter = 0; } @@ -671,7 +716,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Proactive refresh for SoundCloud tracks around 55 seconds (before they expire) if (track._isSoundCloud && position >= 55 && position < 60) { console.log( - `[PlayerContext] SoundCloud track approaching 1min, preparing for refresh at position: ${position}s`, + `[PlayerContext] SoundCloud track approaching 1min, preparing for refresh at position: ${position}s` ); // Could implement pre-emptive refresh here if needed } @@ -686,7 +731,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (positionStuckCounter >= threshold && currentTrack) { console.warn( - `[PlayerContext] Audio position stuck at ${position}s, possible stream failure (${isYouTubeStream ? "YouTube" : "SoundCloud"}, threshold: ${threshold})`, + `[PlayerContext] Audio position stuck at ${position}s, possible stream failure (${isYouTubeStream ? "YouTube" : "SoundCloud"}, threshold: ${threshold})` ); // Try to reload the stream handleStreamFailure(); @@ -695,7 +740,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ positionStuckCounter = 0; } lastPosition = position; - }, + } ); // Add the listener to the cleanup array @@ -710,12 +755,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Refresh cache info at end of song if (currentTrack?.id) { console.log( - `[PlayerContext] Song finished, refreshing cache info for: ${currentTrack.id}`, + `[PlayerContext] Song finished, refreshing cache info for: ${currentTrack.id}` ); const finalCacheInfo = await getCacheInfo(currentTrack.id); console.log( "[PlayerContext] Final cache info at song end:", - finalCacheInfo, + finalCacheInfo ); // Trigger post-playback YouTube caching if this is a YouTube stream @@ -727,17 +772,17 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.audioUrl.includes("piped")) ) { console.log( - `[PlayerContext] Triggering post-playback YouTube caching for: ${currentTrack.id}`, + `[PlayerContext] Triggering post-playback YouTube caching for: ${currentTrack.id}` ); // Don't await this - let it run in background audioManager .cacheYouTubeStreamPostPlayback( currentTrack.audioUrl, - currentTrack.id, + currentTrack.id ) .catch((error) => { console.log( - `[PlayerContext] Post-playback YouTube caching failed: ${error}`, + `[PlayerContext] Post-playback YouTube caching failed: ${error}` ); }); } @@ -748,10 +793,10 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ nextTrack(); } else { console.log( - "[PlayerContext] Skipping auto-next due to ongoing transition/loading", + "[PlayerContext] Skipping auto-next due to ongoing transition/loading" ); } - }, + } ); // Add the listener to the cleanup array @@ -765,7 +810,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ setIsTransitioning(false); } }, - [audioManager, isLoading, isTransitioning], + [audioManager, isLoading, isTransitioning] ); const playPause = useCallback(async () => { @@ -793,14 +838,14 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log("[PlayerContext] Clearing audio monitoring"); // Track Player doesn't use playback status updates like Expo AV console.log( - "[PlayerContext] Audio monitoring cleared (Track Player handles this internally)", + "[PlayerContext] Audio monitoring cleared (Track Player handles this internally)" ); }, []); const nextTrack = useCallback(async () => { console.log("[PlayerContext] nextTrack() called"); console.log( - `[PlayerContext] Playlist length: ${playlist.length}, current index: ${currentIndex}, repeat mode: ${repeatMode}`, + `[PlayerContext] Playlist length: ${playlist.length}, current index: ${currentIndex}, repeat mode: ${repeatMode}` ); // Basic validation @@ -825,7 +870,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Handle repeat one mode - replay current track if (repeatMode === "one" && currentTrack) { console.log( - "[PlayerContext] nextTrack() - Repeat one mode, replaying current track", + "[PlayerContext] nextTrack() - Repeat one mode, replaying current track" ); await playTrack(currentTrack, playlist, currentIndex); return; @@ -836,12 +881,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log("[PlayerContext] nextTrack() - Single song playlist"); if (repeatMode === "one" || repeatMode === "all") { console.log( - "[PlayerContext] nextTrack() - Single song with repeat, replaying", + "[PlayerContext] nextTrack() - Single song with repeat, replaying" ); await playTrack(currentTrack!, playlist, 0); } else { console.log( - "[PlayerContext] nextTrack() - Single song, no repeat, stopping", + "[PlayerContext] nextTrack() - Single song, no repeat, stopping" ); await trackPlayerService.stop(); setIsPlaying(false); @@ -855,12 +900,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (nextTrackItem) { console.log( - `[PlayerContext] nextTrack() - Playing next track at index ${nextIndex}: ${nextTrackItem.title}`, + `[PlayerContext] nextTrack() - Playing next track at index ${nextIndex}: ${nextTrackItem.title}` ); await playTrack(nextTrackItem, playlist, nextIndex); } else { console.log( - `[PlayerContext] nextTrack() - No track found at index ${nextIndex}`, + `[PlayerContext] nextTrack() - No track found at index ${nextIndex}` ); // If no track found, try to stop playback gracefully await trackPlayerService.stop(); @@ -895,7 +940,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ : playlist; console.log( - `[PlayerContext] Playlist length: ${currentPlaylist.length}, current index: ${currentIndex}, repeat mode: ${repeatMode}`, + `[PlayerContext] Playlist length: ${currentPlaylist.length}, current index: ${currentIndex}, repeat mode: ${repeatMode}` ); // Basic validation @@ -920,7 +965,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Handle repeat one mode - replay current track if (repeatMode === "one" && currentTrack) { console.log( - "[PlayerContext] previousTrack() - Repeat one mode, replaying current track", + "[PlayerContext] previousTrack() - Repeat one mode, replaying current track" ); await playTrack(currentTrack, currentPlaylist, currentIndex); return; @@ -931,12 +976,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log("[PlayerContext] previousTrack() - Single song playlist"); if (repeatMode === "one" || repeatMode === "all") { console.log( - "[PlayerContext] previousTrack() - Single song with repeat, replaying", + "[PlayerContext] previousTrack() - Single song with repeat, replaying" ); await playTrack(currentTrack!, currentPlaylist, 0); } else { console.log( - "[PlayerContext] previousTrack() - Single song, no repeat, stopping", + "[PlayerContext] previousTrack() - Single song, no repeat, stopping" ); await trackPlayerService.stop(); setIsPlaying(false); @@ -951,12 +996,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (prevTrack) { console.log( - `[PlayerContext] previousTrack() - Playing previous track at index ${prevIndex}: ${prevTrack.title}`, + `[PlayerContext] previousTrack() - Playing previous track at index ${prevIndex}: ${prevTrack.title}` ); await playTrack(prevTrack, playlist, prevIndex); } else { console.log( - `[PlayerContext] previousTrack() - No track found at index ${prevIndex}`, + `[PlayerContext] previousTrack() - No track found at index ${prevIndex}` ); // If no track found, try to stop playback gracefully await trackPlayerService.stop(); @@ -984,7 +1029,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const seekTo = useCallback( async (position: number) => { console.log( - `[PlayerContext] seekTo called - position: ${position}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}`, + `[PlayerContext] seekTo called - position: ${position}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}` ); if (!currentTrack?.audioUrl) { @@ -1013,7 +1058,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.source === "jiosaavn") ) { console.log( - `[PlayerContext] Checking if position ${position}ms is cached for track: ${currentTrack.id}`, + `[PlayerContext] Checking if position ${position}ms is cached for track: ${currentTrack.id}` ); try { @@ -1023,15 +1068,15 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const positionCheck = await manager.isPositionCached( currentTrack.id, - position, + position ); console.log( - `[PlayerContext] Position cache check: isCached=${positionCheck.isCached}, cacheEnd=${positionCheck.estimatedCacheEndMs}ms`, + `[PlayerContext] Position cache check: isCached=${positionCheck.isCached}, cacheEnd=${positionCheck.estimatedCacheEndMs}ms` ); if (!positionCheck.isCached) { console.warn( - `[PlayerContext] Position ${position}ms is not cached (cache ends at ${positionCheck.estimatedCacheEndMs}ms). Attempting to cache more...`, + `[PlayerContext] Position ${position}ms is not cached (cache ends at ${positionCheck.estimatedCacheEndMs}ms). Attempting to cache more...` ); // Set loading state to indicate we're caching @@ -1041,7 +1086,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // For YouTube tracks, use position-based caching if (currentTrack.source === "youtube") { console.log( - `[PlayerContext] Starting position-based caching from ${position}ms for YouTube track: ${currentTrack.id}`, + `[PlayerContext] Starting position-based caching from ${position}ms for YouTube track: ${currentTrack.id}` ); try { const { cacheYouTubeStreamFromPosition } = @@ -1055,16 +1100,16 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.audioUrl, currentTrack.id, position / 1000, // Convert ms to seconds - seekCacheController, + seekCacheController ); console.log( - `[PlayerContext] Position-based caching completed, cached URL: ${cachedUrl}`, + `[PlayerContext] Position-based caching completed, cached URL: ${cachedUrl}` ); } catch (seekCacheError) { console.error( `[PlayerContext] Position-based caching failed for ${currentTrack.id}:`, - seekCacheError, + seekCacheError ); // Fallback to regular cache monitoring const { monitorAndResumeCache } = @@ -1075,14 +1120,14 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.audioUrl, (percentage) => { console.log( - `[PlayerContext] Fallback cache completion progress: ${percentage}%`, + `[PlayerContext] Fallback cache completion progress: ${percentage}%` ); setCacheProgress({ trackId: currentTrack.id, percentage: percentage, fileSize: 0, // Will be updated when cache info is fetched }); - }, + } ); } } else { @@ -1095,14 +1140,14 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.audioUrl, (percentage) => { console.log( - `[PlayerContext] Cache completion progress: ${percentage}%`, + `[PlayerContext] Cache completion progress: ${percentage}%` ); setCacheProgress({ trackId: currentTrack.id, percentage: percentage, fileSize: 0, // Will be updated when cache info is fetched }); - }, + } ); } @@ -1114,17 +1159,17 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ setIsLoading(false); console.log( - "[PlayerContext] Seeking to uncached position - will resume when ready", + "[PlayerContext] Seeking to uncached position - will resume when ready" ); } else { console.log( - `[PlayerContext] Position ${position}ms is within cached range`, + `[PlayerContext] Position ${position}ms is within cached range` ); } } catch (cacheCheckError) { console.error( "[PlayerContext] Error checking position cache:", - cacheCheckError, + cacheCheckError ); // Continue with seek even if cache check fails - better to try than block } @@ -1153,7 +1198,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.error("[PlayerContext] Error seeking:", error); } else { console.log( - "[PlayerContext] Seek failed - player no longer exists (expected during cleanup)", + "[PlayerContext] Seek failed - player no longer exists (expected during cleanup)" ); } throw error; @@ -1165,7 +1210,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack?._isSoundCloud, currentTrack?.source, isPlaying, - ], + ] ); const handleStreamFailure = useCallback(async () => { @@ -1177,6 +1222,15 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ return; } + // Check if stream has already failed to prevent retries + if (hasStreamFailed) { + console.warn("[PlayerContext] Stream has already failed, not retrying"); + return; + } + + // Set the stream failed flag to prevent retries + setHasStreamFailed(true); + // Check if this is a YouTube stream and if it's very early in playback const isYouTubeStream = currentTrack.audioUrl && @@ -1196,7 +1250,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // For YouTube streams in the first 5 seconds, be more conservative if (isYouTubeStream && currentPosition < 5000) { console.warn( - "[PlayerContext] YouTube stream failure in early phase, waiting before reload...", + "[PlayerContext] YouTube stream failure in early phase, waiting before reload..." ); // Don't reload immediately for YouTube in early phase - might be normal buffering return; @@ -1205,7 +1259,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Check retry limit to prevent infinite loops if (streamRetryCount >= 3) { console.error( - "[PlayerContext] Maximum stream retry attempts reached, giving up", + "[PlayerContext] Maximum stream retry attempts reached, giving up" ); setIsPlaying(false); setIsLoading(false); @@ -1215,7 +1269,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ setStreamRetryCount((prev) => prev + 1); console.log( - `[PlayerContext] Current track: ${currentTrack.title} by ${currentTrack.artist}`, + `[PlayerContext] Current track: ${currentTrack.title} by ${currentTrack.artist}` ); console.log(`[PlayerContext] Current audio URL: ${currentTrack.audioUrl}`); @@ -1253,15 +1307,15 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ undefined, "soundcloud", currentTrack.title, - currentTrack.artist, + currentTrack.artist ); console.log( - `[PlayerContext] Got fresh SoundCloud URL: ${newAudioUrl}`, + `[PlayerContext] Got fresh SoundCloud URL: ${newAudioUrl}` ); } catch (error) { console.error( "[PlayerContext] Failed to get fresh SoundCloud URL:", - error, + error ); // Keep existing URL as fallback } @@ -1273,36 +1327,36 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ undefined, currentTrack._isSoundCloud ? "soundcloud" : "youtube", currentTrack.title, - currentTrack.artist, + currentTrack.artist ); console.log(`[PlayerContext] Got fresh URL: ${newAudioUrl}`); } catch (error) { console.error( "[PlayerContext] Failed to get fresh audio URL:", - error, + error ); } } if (newAudioUrl) { console.log( - `[PlayerContext] Creating new sound with URL: ${newAudioUrl}`, + `[PlayerContext] Creating new sound with URL: ${newAudioUrl}` ); console.log( - `[PlayerContext] URL starts with file://: ${newAudioUrl.startsWith("file://")}`, + `[PlayerContext] URL starts with file://: ${newAudioUrl.startsWith("file://")}` ); console.log( - `[PlayerContext] URL contains double file://: ${newAudioUrl.includes("file://file://")}`, + `[PlayerContext] URL contains double file://: ${newAudioUrl.includes("file://file://")}` ); // Check if file exists for local files if (newAudioUrl.startsWith("file://")) { try { const fileInfo = await FileSystem.getInfoAsync( - newAudioUrl.replace("file://", ""), + newAudioUrl.replace("file://", "") ); console.log( - `[PlayerContext] File exists check: ${fileInfo.exists}${fileInfo.exists ? `, size: ${fileInfo.size}` : ""}`, + `[PlayerContext] File exists check: ${fileInfo.exists}${fileInfo.exists ? `, size: ${fileInfo.size}` : ""}` ); } catch (error) { console.log(`[PlayerContext] File check error: ${error}`); @@ -1313,13 +1367,13 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ try { await trackPlayerService.updateCurrentTrack(newAudioUrl); console.log( - "[PlayerContext] Updated track in Track Player with new audio URL", + "[PlayerContext] Updated track in Track Player with new audio URL" ); // Seek to previous position if (currentPosition > 0) { console.log( - `[PlayerContext] Seeking to previous position: ${currentPosition}ms`, + `[PlayerContext] Seeking to previous position: ${currentPosition}ms` ); try { await trackPlayerService.seekTo(currentPosition / 1000); // Convert ms to seconds @@ -1331,7 +1385,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } catch (error) { console.error( "[PlayerContext] Failed to update track in Track Player:", - error, + error ); } @@ -1342,12 +1396,12 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ try { await trackPlayerService.updateCurrentTrack(newAudioUrl); console.log( - "[PlayerContext] Updated track in Track Player with new audio URL", + "[PlayerContext] Updated track in Track Player with new audio URL" ); } catch (error) { console.error( "[PlayerContext] Failed to update track in Track Player:", - error, + error ); } @@ -1389,18 +1443,18 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (timeSinceLastProgress > 3000) { // No progress in 3 seconds console.warn( - "[PlayerContext] Possible cache exhaustion detected, reloading stream...", + "[PlayerContext] Possible cache exhaustion detected, reloading stream..." ); handleStreamFailure(); } } else if (currentPosition > 300 && isTransitioning) { console.log( - "[PlayerContext] Skipping cache exhaustion check during transition", + "[PlayerContext] Skipping cache exhaustion check during transition" ); } lastPosition = currentPosition; - }, + } ); // Store the listener for cleanup @@ -1409,7 +1463,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log("[PlayerContext] === STREAM RELOADED SUCCESSFULLY ==="); } else { console.warn( - "[PlayerContext] Could not get fresh audio URL for reload", + "[PlayerContext] Could not get fresh audio URL for reload" ); } } catch (error) { @@ -1422,7 +1476,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ console.log("[PlayerContext] Stopping all continuous caching operations"); cacheControllersRef.current.forEach((controller, trackId) => { console.log( - `[PlayerContext] Aborting continuous caching for track: ${trackId}`, + `[PlayerContext] Aborting continuous caching for track: ${trackId}` ); controller.abort(); }); @@ -1477,7 +1531,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Create shuffled playlist (excluding current track) const currentTrackItem = playlist[currentIndex]; const remainingTracks = playlist.filter( - (_, index) => index !== currentIndex, + (_, index) => index !== currentIndex ); // Fisher-Yates shuffle @@ -1497,7 +1551,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Restore original playlist order const currentTrackItem = playlist[currentIndex]; const originalIndex = originalPlaylistRef.current.findIndex( - (track) => track.id === currentTrackItem?.id, + (track) => track.id === currentTrackItem?.id ); setPlaylist(originalPlaylistRef.current); @@ -1520,7 +1574,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Start caching the liked song if (track.id && track.audioUrl) { console.log( - `[PlayerContext] Starting to cache liked song: ${track.title} (${track.id})`, + `[PlayerContext] Starting to cache liked song: ${track.title} (${track.id})` ); // Import and start caching in background @@ -1534,18 +1588,18 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ cacheController, (percentage) => { console.log( - `[PlayerContext] Liked song cache progress: ${percentage}%`, + `[PlayerContext] Liked song cache progress: ${percentage}%` ); setCacheProgress({ trackId: track.id, percentage: percentage, fileSize: 0, }); - }, + } ).catch((error) => { console.error( `[PlayerContext] Failed to cache liked song ${track.id}:`, - error, + error ); }); @@ -1558,7 +1612,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ .catch((error) => { console.error( "[PlayerContext] Failed to import caching module:", - error, + error ); }); } @@ -1591,13 +1645,13 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (cacheInfo.isDownloading) { console.log( - `[PlayerContext] Song already downloading: ${song.title}`, + `[PlayerContext] Song already downloading: ${song.title}` ); continue; } console.log( - `[PlayerContext] Starting to cache liked song: ${song.title} (${song.id})`, + `[PlayerContext] Starting to cache liked song: ${song.title} (${song.id})` ); const cacheController = new AbortController(); @@ -1608,18 +1662,18 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ cacheController, (percentage) => { console.log( - `[PlayerContext] Background cache progress for ${song.title}: ${percentage}%`, + `[PlayerContext] Background cache progress for ${song.title}: ${percentage}%` ); setCacheProgress({ trackId: song.id, percentage: percentage, fileSize: 0, }); - }, + } ).catch((error) => { console.error( `[PlayerContext] Failed to cache liked song ${song.id}:`, - error, + error ); }); @@ -1635,34 +1689,34 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } console.log( - `[PlayerContext] Started background caching for ${songs.length} liked songs`, + `[PlayerContext] Started background caching for ${songs.length} liked songs` ); } catch (error) { console.error("[PlayerContext] Error caching all liked songs:", error); } }, - [audioManager], + [audioManager] ); const isSongLiked = useCallback( (trackId: string) => { return likedSongs.some((song) => song.id === trackId); }, - [likedSongs], + [likedSongs] ); const getCacheInfo = useCallback( async (trackId: string) => { return await audioManager.getCacheInfo(trackId); }, - [audioManager], + [audioManager] ); // Handle notification responses for media controls useEffect(() => { // Skip notification handling since expo-notifications is removed console.log( - "[PlayerContext] Notification handling disabled - expo-notifications removed", + "[PlayerContext] Notification handling disabled - expo-notifications removed" ); return () => {}; }, [playPause, nextTrack, previousTrack, clearPlayer]); @@ -1690,6 +1744,9 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ cacheProgress, isTransitioning, streamRetryCount, + hasStreamFailed, + position, + duration, playTrack, playPause, nextTrack, diff --git a/locales/fa.json b/locales/fa.json index 7e7a717..f33a2f9 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -520,4 +520,4 @@ "live": "زنده" } } -} \ No newline at end of file +} diff --git a/modules/audioStreaming.ts b/modules/audioStreaming.ts index 64ab23c..06fdaab 100644 --- a/modules/audioStreaming.ts +++ b/modules/audioStreaming.ts @@ -1,7 +1,11 @@ import { Audio } from "expo-av"; import * as FileSystem from "expo-file-system/legacy"; import { toByteArray, fromByteArray } from "base64-js"; -import { API, fetchWithRetry } from "../components/core/api"; +import { + API, + fetchWithRetry, + DYNAMIC_INVIDIOUS_INSTANCES, +} from "../components/core/api"; // Cache directory configuration const CACHE_CONFIG = { @@ -3021,23 +3025,32 @@ export class AudioStreamManager { } private setupFallbackStrategies() { - // Strategy 1: YouTube Omada (fastest for YouTube - added for priority) + // Strategy 1: Invidious API (with dynamic instances - highest priority for YouTube) + this.fallbackStrategies.push(this.tryInvidious.bind(this)); + + // Strategy 2: Piped API (alternative to Invidious) + this.fallbackStrategies.push(this.tryPiped.bind(this)); + + // Strategy 3: YouTube Omada (fast fallback) this.fallbackStrategies.push(this.tryYouTubeOmada.bind(this)); - // Strategy 2: Local extraction server (if available) + + // Strategy 4: Local extraction server (if available) this.fallbackStrategies.push(this.tryLocalExtraction.bind(this)); - // Strategy 3: SoundCloud API (high priority for music) + + // Strategy 5: SoundCloud API (high priority for music) // this.fallbackStrategies.push(this.trySoundCloud.bind(this)); - // Strategy 4: YouTube Music extraction + + // Strategy 6: YouTube Music extraction this.fallbackStrategies.push(this.tryYouTubeMusic.bind(this)); - // Strategy 5: Spotify Web API (requires auth but has good coverage) + + // Strategy 7: Spotify Web API (requires auth but has good coverage) // this.fallbackStrategies.push(this.trySpotifyWebApi.bind(this)); - // Strategy 6: Hyperpipe API + + // Strategy 8: Hyperpipe API this.fallbackStrategies.push(this.tryHyperpipe.bind(this)); - // Strategy 7: Piped API (alternative to Invidious) - this.fallbackStrategies.push(this.tryPiped.bind(this)); - // Strategy 8: YouTube embed extraction (last resort) + + // Strategy 9: YouTube embed extraction (last resort) this.fallbackStrategies.push(this.tryYouTubeEmbed.bind(this)); - // Note: YouTube Omada is handled exclusively in getAudioUrl for youtube/yt sources } async getAudioUrl( @@ -3058,6 +3071,16 @@ export class AudioStreamManager { trackArtist, }); + // Log available strategies for debugging + console.log( + `[AudioStreamManager] Available strategies: ${this.fallbackStrategies.length}` + ); + this.fallbackStrategies.forEach((strategy, index) => { + console.log( + `[AudioStreamManager] Strategy ${index + 1}: ${this.getStrategyName(strategy)}` + ); + }); + // Check if we have a prefetched result const prefetched = this.prefetchQueue.get(videoId); if (prefetched) { @@ -3113,45 +3136,44 @@ export class AudioStreamManager { } } - // --- YOUTUBE EXCLUSIVE HANDLING (only use YouTube Omada API) --- + // --- YOUTUBE EXCLUSIVE HANDLING (try multiple strategies) --- if (source === "youtube" || source === "yt") { - onStatusUpdate?.("Using YouTube Omada API (exclusive)"); + onStatusUpdate?.("Using YouTube strategies"); console.log( `[AudioStreamManager] YouTube mode activated for: ${videoId}` ); - try { - console.log( - `[Audio] Attempting YouTube Omada extraction for track: ${videoId}` - ); - const youtubeUrl = await this.tryYouTubeOmada(videoId); - if (youtubeUrl) { + // Try fallback strategies for YouTube (including Invidious) + for (let i = 0; i < this.fallbackStrategies.length; i++) { + const strategy = this.fallbackStrategies[i]; + const strategyName = this.getStrategyName(strategy); + + try { console.log( - `[AudioStreamManager] YouTube Omada returned URL: ${youtubeUrl.substring(0, 100)}...` + `[AudioStreamManager] Trying ${strategyName} for YouTube: ${videoId}` ); - // Cache the YouTube stream and return cached file path - onStatusUpdate?.("Caching YouTube audio..."); - const controller = new AbortController(); - const cachedUrl = await this.cacheYouTubeStream( - youtubeUrl, - videoId, - controller + onStatusUpdate?.(`Trying ${strategyName}...`); + + const url = await strategy(videoId); + if (url) { + console.log( + `[AudioStreamManager] ${strategyName} returned URL: ${url.substring(0, 100)}...` + ); + // Return the raw stream URL - caching will be handled by PlayerContext if needed + return url; + } + } catch (error) { + console.warn( + `[AudioStreamManager] ${strategyName} failed for YouTube ${videoId}:`, + error instanceof Error ? error.message : error ); - return cachedUrl; - } else { - console.error("[AudioStreamManager] YouTube Omada returned no URL"); - throw new Error("YouTube Omada extraction returned no URL"); + // Continue to next strategy + continue; } - } catch (error) { - // YouTube Omada strategy failed, do not try fallback strategies - console.error( - "[AudioStreamManager] YouTube Omada extraction failed:", - error - ); - throw new Error( - `YouTube playback failed: ${error instanceof Error ? error.message : "Unknown error"}` - ); } + + // All strategies failed + throw new Error("All YouTube strategies failed"); } // --- JIOSAAVN HANDLING (exclusive - no fallbacks) --- @@ -3210,8 +3232,11 @@ export class AudioStreamManager { const concurrentPromises = this.fallbackStrategies .slice(0, 3) .map(async (strategy, index) => { - const strategyName = strategy.name || `Strategy ${index + 1}`; + const strategyName = this.getStrategyName(strategy); const startTime = Date.now(); + console.log( + `[AudioStreamManager] Concurrent test: ${strategyName} for ${videoId}` + ); try { const url = await Promise.race([ strategy(videoId), @@ -3220,8 +3245,14 @@ export class AudioStreamManager { ), ]); const latency = Date.now() - startTime; + console.log( + `[AudioStreamManager] Concurrent test ${strategyName}: ${url ? "SUCCESS" : "FAILED"} (${latency}ms)` + ); return { url, latency, strategy: strategyName }; } catch (error) { + console.log( + `[AudioStreamManager] Concurrent test ${strategyName}: ERROR - ${error}` + ); return null; } }); @@ -3237,6 +3268,10 @@ export class AudioStreamManager { // Sort by latency and return fastest result successfulResults.sort((a, b) => a.latency - b.latency); const fastest = successfulResults[0]; + console.log( + `[AudioStreamManager] Concurrent test found fastest strategy: ${fastest.strategy} (${fastest.latency}ms)` + ); + console.log(`[AudioStreamManager] Fastest URL: ${fastest.url}`); onStatusUpdate?.(`Fastest: ${fastest.strategy} (${fastest.latency}ms)`); // Apply caching based on the strategy type @@ -3283,11 +3318,17 @@ export class AudioStreamManager { for (let i = 0; i < this.fallbackStrategies.length; i++) { const strategy = this.fallbackStrategies[i]; - const strategyName = strategy.name || `Strategy ${i + 1}`; + const strategyName = this.getStrategyName(strategy); try { onStatusUpdate?.(`Trying ${strategyName}...`); + console.log( + `[AudioStreamManager] Attempting strategy: ${strategyName} for videoId: ${videoId}` + ); const url = await strategy(videoId); + console.log( + `[AudioStreamManager] Strategy ${strategyName} returned: ${url ? "SUCCESS" : "FAILED"}` + ); if (url) { onStatusUpdate?.(`Success with ${strategyName}`); @@ -3318,6 +3359,9 @@ export class AudioStreamManager { } } + console.log( + `[AudioStreamManager] Strategy ${strategyName} succeeded with URL: ${url}` + ); return url; } } catch (error) { @@ -3330,9 +3374,15 @@ export class AudioStreamManager { } } - throw new Error( - `All audio extraction strategies failed. Errors: ${errors.join("; ")}` + const finalError = `All audio extraction strategies failed. Errors: ${errors.join("; ")}`; + console.error(`[AudioStreamManager] ${finalError}`); + console.error( + `[AudioStreamManager] Total strategies attempted: ${errors.length}` + ); + console.error( + `[AudioStreamManager] Failed strategies: ${errors.map((e) => e.split(":")[0]).join(", ")}` ); + throw new Error(finalError); } // Queue prefetch functionality (ytify v8 concept) @@ -3389,7 +3439,9 @@ export class AudioStreamManager { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - const response = await fetch(url, { + // Try direct request first (for YouTube/Invidious instances that work) + console.log(`[AudioStreamManager] Attempting direct request: ${url}`); + let response = await fetch(url, { ...options, signal: controller.signal, headers: { @@ -3400,6 +3452,41 @@ export class AudioStreamManager { ...options.headers, }, }); + + // If direct request fails or is blocked, try with proxy + if ( + !response.ok || + response.status === 403 || + response.status === 429 + ) { + console.log( + `[AudioStreamManager] Direct request failed (${response.status}), trying proxy...` + ); + clearTimeout(timeoutId); + + const proxyUrl = this.getNextProxy(); + const proxiedUrl = proxyUrl + encodeURIComponent(url); + console.log(`[AudioStreamManager] Fetching via proxy: ${proxiedUrl}`); + + const proxyController = new AbortController(); + const proxyTimeoutId = setTimeout( + () => proxyController.abort(), + timeout + ); + + response = await fetch(proxiedUrl, { + ...options, + signal: proxyController.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + Accept: "application/json, text/plain, * / *", + "Accept-Language": "en-US,en;q=0.9", + ...options.headers, + }, + }); + clearTimeout(proxyTimeoutId); + } clearTimeout(timeoutId); // Enhanced blocking detection @@ -3848,19 +3935,12 @@ export class AudioStreamManager { if (audioFormats.length > 0 && audioFormats[0].url) { // Resolve relative URLs to full URLs - let audioUrl = audioFormats[0].url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, audioFormats[0].url); console.log( "[AudioStreamManager] Found audio via Invidious adaptiveFormats" ); - // Cache the YouTube stream and return cached file path - return this.cacheYouTubeStream( - audioUrl, - videoId, - new AbortController() - ); + // Return direct stream URL (caching should be handled at PlayerContext level for liked songs only) + return audioUrl; } } @@ -3886,10 +3966,7 @@ export class AudioStreamManager { if (audioStreams.length > 0 && audioStreams[0].url) { // Resolve relative URLs to full URLs - let audioUrl = audioStreams[0].url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, audioStreams[0].url); console.log("[AudioStreamManager] Found audio via formatStreams"); // Cache the YouTube stream and return cached file path return this.cacheYouTubeStream( @@ -3916,10 +3993,7 @@ export class AudioStreamManager { if (videoStreamsWithAudio.length > 0 && videoStreamsWithAudio[0].url) { // Resolve relative URLs to full URLs - let audioUrl = videoStreamsWithAudio[0].url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, videoStreamsWithAudio[0].url); console.log( "[AudioStreamManager] Found video stream with audio via formatStreams" ); @@ -3942,10 +4016,7 @@ export class AudioStreamManager { if (anyVideoStream) { // Resolve relative URLs to full URLs - let audioUrl = anyVideoStream.url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, anyVideoStream.url); console.log( "[AudioStreamManager] Using video stream for audio extraction via formatStreams" ); @@ -3981,10 +4052,7 @@ export class AudioStreamManager { if (data.formatStreams && data.formatStreams.length > 0) { const bestStream = data.formatStreams[0]; if (bestStream.url) { - let streamUrl = bestStream.url; - if (streamUrl.startsWith("/")) { - streamUrl = `${instance}${streamUrl}`; - } + let streamUrl = this.resolveRelativeUrl(instance, bestStream.url); // Convert video stream to MP3 format using a conversion service console.log( @@ -4009,11 +4077,159 @@ export class AudioStreamManager { ); } + private resolveRelativeUrl(instance: string, relativeUrl: string): string { + if (relativeUrl.startsWith("//")) { + // Handle double slash URLs (common from Invidious API) + // Remove one slash to make it a proper relative URL + const cleanRelativeUrl = relativeUrl.substring(1); + const cleanInstance = instance.endsWith("/") + ? instance.slice(0, -1) + : instance; + return `${cleanInstance}${cleanRelativeUrl}`; + } else if (relativeUrl.startsWith("/")) { + // Handle single slash URLs + const cleanInstance = instance.endsWith("/") + ? instance.slice(0, -1) + : instance; + return `${cleanInstance}${relativeUrl}`; + } + return relativeUrl; + } + + private async tryInvidious(videoId: string): Promise { + // Use dynamic instances if available, otherwise fall back to hardcoded ones + const instances = + DYNAMIC_INVIDIOUS_INSTANCES.length > 0 + ? DYNAMIC_INVIDIOUS_INSTANCES + : ["https://echostreamz.com"]; + + console.log( + `[AudioStreamManager] Invidious trying ${instances.length} instances for video: ${videoId}` + ); + console.log( + `[AudioStreamManager] Available instances: ${instances.join(", ")}` + ); + + for (const instance of instances) { + try { + console.log( + `[AudioStreamManager] Trying Invidious instance: ${instance}` + ); + // Use ?local=true to get proxied URLs that bypass some blocks + const requestUrl = `${instance}/api/v1/videos/${videoId}?local=true`; + console.log(`[AudioStreamManager] Invidious request: ${requestUrl}`); + + const response = await this.fetchWithProxy( + requestUrl, + { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + Accept: "application/json, text/plain, *\/\*", + "Accept-Language": "en-US,en;q=0.9", + }, + }, + 2, // 2 retries + 12000 // 12 second timeout + ); + + console.log( + `[AudioStreamManager] Invidious response status: ${response.status}` + ); + + if (!response.ok) { + console.warn( + `[AudioStreamManager] Invidious instance ${instance} returned ${response.status}` + ); + continue; + } + + // Check if response is HTML (blocked) instead of JSON + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("json")) { + console.warn( + `[AudioStreamManager] Invidious instance ${instance} returned HTML instead of JSON (blocked)` + ); + continue; + } + + const data = await response.json(); + console.log( + `[AudioStreamManager] Invidious response data keys: ${Object.keys(data).join(", ")}` + ); + + // Check for adaptive formats (primary method) + if (data.adaptiveFormats) { + const audioFormats = data.adaptiveFormats + .filter( + (f: any) => + f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/") + ) + .sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0)); + + if (audioFormats.length > 0 && audioFormats[0].url) { + // Resolve relative URLs to full URLs + let audioUrl = this.resolveRelativeUrl( + instance, + audioFormats[0].url + ); + console.log( + `[AudioStreamManager] Found audio via Invidious instance ${instance} adaptiveFormats` + ); + // Return direct stream URL (caching should be handled at PlayerContext level for liked songs only) + return audioUrl; + } + } + + // Fallback to formatStreams if adaptiveFormats not available + if (data.formatStreams) { + console.log( + `[AudioStreamManager] Found formatStreams: ${data.formatStreams.length} streams from ${instance}` + ); + // First try to find audio-only streams + const audioStreams = data.formatStreams + .filter( + (f: any) => + f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/") + ) + .sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0)); + + if (audioStreams.length > 0 && audioStreams[0].url) { + // Resolve relative URLs to full URLs + let audioUrl = this.resolveRelativeUrl( + instance, + audioStreams[0].url + ); + console.log( + `[AudioStreamManager] Found audio via formatStreams from ${instance}` + ); + // Return direct stream URL (caching should be handled at PlayerContext level for liked songs only) + return audioUrl; + } + } + + console.warn( + `[AudioStreamManager] No audio formats found from Invidious instance ${instance}` + ); + } catch (error) { + console.warn( + `[AudioStreamManager] Invidious instance ${instance} failed:`, + error + ); + continue; + } + } + + throw new Error("All Invidious instances failed"); + } + private async tryYouTubeOmada(videoId: string): Promise { const instance = this.getOmadaProxyUrl(); + console.log(`[YouTube Omada] Using instance: ${instance}`); try { const requestUrl = `${instance}/api/v1/videos/${videoId}`; + console.log(`[YouTube Omada] Requesting: ${requestUrl}`); // Use the new yt.omada.cafe endpoint for YouTube playback const response = await this.fetchWithProxy( @@ -4031,9 +4247,12 @@ export class AudioStreamManager { ); if (!response.ok) { + console.error(`[YouTube Omada] HTTP error: ${response.status}`); throw new Error(`YouTube Omada returned ${response.status}`); } + console.log(`[YouTube Omada] Response status: ${response.status}`); + // Check if response is HTML (blocked) instead of JSON const contentType = response.headers.get("content-type"); if (!contentType?.includes("json")) { @@ -4043,6 +4262,9 @@ export class AudioStreamManager { } const data = await response.json(); + console.log( + `[YouTube Omada] Response data keys: ${Object.keys(data).join(", ")}` + ); // Check for adaptive formats (primary method) if (data.adaptiveFormats) { @@ -4076,10 +4298,7 @@ export class AudioStreamManager { const audioFormat = audioFormats[i]; if (audioFormat.url) { // Resolve relative URLs to full URLs - let audioUrl = audioFormat.url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, audioFormat.url); // Check if this is a GoogleVideo URL that might need proxying let useOmadaProxy = false; @@ -4139,10 +4358,7 @@ export class AudioStreamManager { const audioStream = audioStreams[i]; if (audioStream.url) { // Resolve relative URLs to full URLs - let audioUrl = audioStream.url; - if (audioUrl.startsWith("/")) { - audioUrl = `${instance}${audioUrl}`; - } + let audioUrl = this.resolveRelativeUrl(instance, audioStream.url); // Check if this is a GoogleVideo URL that needs proxying through Omada if (audioUrl.includes("googlevideo.com")) { @@ -4195,10 +4411,7 @@ export class AudioStreamManager { for (let i = 0; i < videoStreams.length; i++) { const videoStream = videoStreams[i]; if (videoStream.url) { - let videoUrl = videoStream.url; - if (videoUrl.startsWith("/")) { - videoUrl = `${instance}${videoUrl}`; - } + let videoUrl = this.resolveRelativeUrl(instance, videoStream.url); // Check if this is a GoogleVideo URL that needs proxying through Omada if (videoUrl.includes("googlevideo.com")) { @@ -5028,6 +5241,23 @@ export class AudioStreamManager { return {}; } + // Helper method to get strategy name from function + private getStrategyName(strategy: Function): string { + const strategyMap = new Map([ + [this.tryInvidious, "Invidious"], + [this.tryPiped, "Piped"], + [this.tryYouTubeOmada, "YouTube Omada"], + [this.tryLocalExtraction, "Local Extraction"], + [this.trySoundCloud, "SoundCloud"], + [this.tryYouTubeMusic, "YouTube Music"], + [this.trySpotifyWebApi, "Spotify Web API"], + [this.tryHyperpipe, "Hyperpipe"], + [this.tryYouTubeEmbed, "YouTube Embed"], + ]); + + return strategyMap.get(strategy) || "Unknown Strategy"; + } + // Cleanup method /** diff --git a/modules/searchAPI.ts b/modules/searchAPI.ts index ecd0e04..610fd42 100644 --- a/modules/searchAPI.ts +++ b/modules/searchAPI.ts @@ -116,7 +116,7 @@ const fetchWithFallbacks = async ( console.log(`[API] ✅ Successfully parsed JSON from ${baseUrl}`); return parsed; } else { - console.log(`[API] Response doesn't start with JSON`); + console.log("[API] Response doesn't start with JSON"); } } catch (e) { console.log(`[API] Failed to parse JSON from ${baseUrl}:`, e.message); @@ -274,12 +274,14 @@ export const searchAPI = { } }, + // COMMENTED OUT: JioSaavn search disabled to focus on YouTube + /* // --- JIOSAAVN SEARCH --- searchWithJioSaavn: async ( query: string, filter?: string, page?: number, - limit?: number + limit?: number, ) => { console.log(`[API] Starting JioSaavn search for: "${query}"`); @@ -294,7 +296,7 @@ export const searchAPI = { }, }, 3, - 1000 + 1000, ); if (!data || !data.success || !data.data) { @@ -347,24 +349,24 @@ export const searchAPI = { }); console.log( - `[API] Filtered ${artists.length} artists to ${filteredArtists.length} relevant artists` + `[API] Filtered ${artists.length} artists to ${filteredArtists.length} relevant artists`, ); // Log exact matches for debugging const exactMatches = filteredArtists.filter( (artist: any) => (artist.title || "").toLowerCase().trim() === - query.toLowerCase().trim() + query.toLowerCase().trim(), ); if (exactMatches.length > 0) { console.log( `[API] Found ${exactMatches.length} exact artist matches for "${query}":`, - exactMatches.map((a: any) => a.title) + exactMatches.map((a: any) => a.title), ); } console.log( - `[API] 🟢 JioSaavn Success: Found ${songs.length} songs, ${albums.length} albums, ${artists.length} artists, ${topQuery.length} top queries` + `[API] 🟢 JioSaavn Success: Found ${songs.length} songs, ${albums.length} albums, ${artists.length} artists, ${topQuery.length} top queries`, ); // Format all results to match SearchResult interface @@ -498,7 +500,8 @@ export const searchAPI = { // Check for exact artist matches in the filtered artists const exactArtistMatches = artistsResults.filter( - (item) => item.title.toLowerCase().trim() === query.toLowerCase().trim() + (item) => + item.title.toLowerCase().trim() === query.toLowerCase().trim(), ); // Build final result array in the correct order: Top Results (with artist first if exact match), Songs, Albums @@ -526,7 +529,8 @@ export const searchAPI = { // Add remaining artists (non-exact matches) const remainingArtists = artistsResults.filter( - (item) => item.title.toLowerCase().trim() !== query.toLowerCase().trim() + (item) => + item.title.toLowerCase().trim() !== query.toLowerCase().trim(), ); if (remainingArtists.length > 0) { finalResults = [...finalResults, ...remainingArtists]; @@ -539,7 +543,10 @@ export const searchAPI = { return []; } }, + */ + // COMMENTED OUT: JioSaavn song details disabled to focus on YouTube + /* // --- JIOSAAVN SONG DETAILS --- getJioSaavnSongDetails: async (songId: string) => { console.log(`[API] Fetching JioSaavn song details for: "${songId}"`); @@ -608,7 +615,10 @@ export const searchAPI = { return null; } }, + */ + // COMMENTED OUT: JioSaavn album details disabled to focus on YouTube + /* // --- JIOSAAVN ALBUM DETAILS --- getJioSaavnAlbumDetails: async (albumId: string, albumName: string) => { console.log( @@ -746,6 +756,7 @@ export const searchAPI = { return null; } }, + */ // --- YOUTUBE PLAYLIST DETAILS --- getYouTubePlaylistDetails: async (playlistId: string) => { @@ -768,7 +779,7 @@ export const searchAPI = { ); const data = await fetchWithFallbacks([...PIPED_INSTANCES], endpoint); console.log( - `[API] fetchWithFallbacks returned:`, + "[API] fetchWithFallbacks returned:", data ? "data object" : "null" ); @@ -1283,7 +1294,9 @@ export const searchAPI = { return result; }) .filter((item): item is SearchResult => { - if (item === null || item.id === "") return false; + if (item === null || item.id === "") { + return false; + } // Filter out album items for YouTube and YouTube Music sources if ( diff --git a/modules/store.ts b/modules/store.ts index ee82ab0..15d11bf 100644 --- a/modules/store.ts +++ b/modules/store.ts @@ -70,7 +70,7 @@ if (savedStore) { export function setState( key: K, - val: AppSettings[K] + val: AppSettings[K], ) { state[key] = val; const str = JSON.stringify(state); diff --git a/package-lock.json b/package-lock.json index c327c66..c4f194b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13206,8 +13206,6 @@ "react-native": "*" } }, - - "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", diff --git a/services/TrackPlayerService.ts b/services/TrackPlayerService.ts index bb4fe8e..d230309 100644 --- a/services/TrackPlayerService.ts +++ b/services/TrackPlayerService.ts @@ -1,242 +1,112 @@ import TrackPlayer, { - Event as TrackPlayerEvent, - Capability as TrackPlayerCapability, - RepeatMode, AppKilledPlaybackBehavior, - State, + Capability, + Event, IOSCategory, IOSCategoryMode, IOSCategoryOptions, PitchAlgorithm, -} from "../utils/safeTrackPlayer"; -import { t } from "../utils/localization"; -import { Platform, NativeModules } from "react-native"; + RepeatMode, + State, + Track as TrackPlayerTrack, +} from "react-native-track-player"; import { Track } from "../contexts/PlayerContext"; +import { StorageService } from "../utils/storage"; +import { NativeModules } from "react-native"; +import { t } from "../utils/localization"; -// Comprehensive fallback constants for TrackPlayer capabilities -const CAPABILITY_FALLBACKS = { - Play: "play", - Pause: "pause", - SkipToNext: "skipToNext", - SkipToPrevious: "skipToPrevious", - Stop: "stop", - SeekTo: "seekTo", - JumpForward: "jumpForward", - JumpBackward: "jumpBackward", - Like: "like", - Dislike: "dislike", - Bookmark: "bookmark", -} as const; - -// Fallback constants for TrackPlayer events -const EVENT_FALLBACKS = { - RemotePlay: "remote-play", - RemotePause: "remote-pause", - RemoteStop: "remote-stop", - RemoteNext: "remote-next", - RemotePrevious: "remote-previous", - RemoteSeek: "remote-seek", - RemoteDuck: "remote-duck", - PlaybackQueueEnded: "playback-queue-ended", - PlaybackTrackChanged: "playback-track-changed", - PlaybackProgressUpdated: "playback-progress-updated", -} as const; - -// Safe capability constants with fallbacks -const Capability = TrackPlayerCapability || CAPABILITY_FALLBACKS; -const Event = TrackPlayerEvent || EVENT_FALLBACKS; - -// Additional safety check for individual capability constants -const getSafeCapability = ( - capabilityName: keyof typeof CAPABILITY_FALLBACKS, -): any => { - try { - // Return the actual Capability enum value if available, otherwise use fallback string - return TrackPlayerCapability - ? TrackPlayerCapability[capabilityName] - : CAPABILITY_FALLBACKS[capabilityName]; - } catch (error) { - console.warn( - `[TrackPlayerService] Failed to get capability ${capabilityName}, using fallback`, - ); - return CAPABILITY_FALLBACKS[capabilityName]; +// Safe fallback for TrackPlayer constants +let TrackPlayerCapability: typeof Capability | null = null; +let TrackPlayerEvent: typeof Event | null = null; + +// Try to import constants safely +try { + if (Capability) { + TrackPlayerCapability = Capability; } -}; +} catch (e) { + console.warn("[TrackPlayerService] Failed to import Capability constants"); +} -// Additional safety check for individual event constants -const getSafeEvent = (eventName: keyof typeof EVENT_FALLBACKS): any => { - try { - return Event[eventName] || EVENT_FALLBACKS[eventName]; - } catch (error) { - console.warn( - `[TrackPlayerService] Failed to get event ${eventName}, using fallback`, - ); - return EVENT_FALLBACKS[eventName]; +try { + if (Event) { + TrackPlayerEvent = Event; } -}; +} catch (e) { + console.warn("[TrackPlayerService] Failed to import Event constants"); +} -// Module-level initialization check -const initializeTrackPlayerConstants = () => { - try { - console.log("[TrackPlayerService] Initializing TrackPlayer constants..."); +// Safe wrapper functions for TrackPlayer constants +function getSafeCapability( + capabilityName: keyof typeof Capability +): Capability { + if (TrackPlayerCapability && TrackPlayerCapability[capabilityName]) { + return TrackPlayerCapability[capabilityName]; + } + return Capability[capabilityName]; +} - // Check if TrackPlayer module is available - if (!TrackPlayer) { - console.error("[TrackPlayerService] TrackPlayer module is null!"); - return false; - } +function getSafeEvent(eventName: keyof typeof Event): Event { + if (TrackPlayerEvent && TrackPlayerEvent[eventName]) { + return TrackPlayerEvent[eventName]; + } + return Event[eventName as keyof typeof Event]; +} - // Check if native module is available +// TurboModule compatibility setup function +function setupTurboModuleCompatibility() { + try { + // Check if native module is available (avoids '...setupPlayer of null' errors) const nativeTrackPlayer = (NativeModules as any).TrackPlayerModule || (NativeModules as any).TrackPlayer; if (!nativeTrackPlayer) { - console.error("[TrackPlayerService] Native TrackPlayer module is null!"); - return false; + throw new Error( + "Native TrackPlayer module is not available. If you are using Expo, make sure you are *not* running in Expo Go and that you have rebuilt the app after installing react-native-track-player." + ); } - // Validate capability constants - const capabilityCheck = { - original: TrackPlayerCapability, - fallback: CAPABILITY_FALLBACKS, - usingFallback: !TrackPlayerCapability, - }; - - const eventCheck = { - original: TrackPlayerEvent, - fallback: EVENT_FALLBACKS, - usingFallback: !TrackPlayerEvent, - }; - - console.log( - "[TrackPlayerService] Capability constants check:", - capabilityCheck, - ); - console.log("[TrackPlayerService] Event constants check:", eventCheck); - - // Test if we can access the constants without errors - if (TrackPlayerCapability) { + // TurboModule compatibility check - disable synchronous methods that cause issues + if ( + nativeTrackPlayer && + typeof nativeTrackPlayer.getConstants === "function" + ) { try { - const testCapabilities = [ - TrackPlayerCapability.Play, - TrackPlayerCapability.Pause, - TrackPlayerCapability.SkipToNext, - TrackPlayerCapability.SkipToPrevious, - ]; - console.log( - "[TrackPlayerService] Successfully accessed capability constants", - ); - } catch (error) { + // Temporarily disable synchronous methods that might cause TurboModule issues + const originalGetConstants = nativeTrackPlayer.getConstants; + nativeTrackPlayer.getConstants = function () { + try { + return originalGetConstants.call(this); + } catch (e) { + console.warn( + "[TrackPlayerService] getConstants failed, returning empty object" + ); + return {}; + } + }; + } catch (e) { console.warn( - "[TrackPlayerService] Error accessing capability constants, will use fallbacks:", - error, + "[TrackPlayerService] Failed to wrap native module methods:", + e ); } } - - return true; - } catch (error) { - console.error( - "[TrackPlayerService] Failed to initialize TrackPlayer constants:", - error, - ); - return false; - } -}; - -// Initialize constants immediately when module loads -const constantsInitialized = initializeTrackPlayerConstants(); -console.log( - "[TrackPlayerService] Constants initialization result:", - constantsInitialized, -); - -// TurboModule compatibility workaround -const setupTurboModuleCompatibility = () => { - try { - const TrackPlayerModule = - (NativeModules as any).TrackPlayerModule || - (NativeModules as any).TrackPlayer; - - if ( - TrackPlayerModule && - typeof TrackPlayerModule.getConstants === "function" - ) { - // Wrap getConstants to handle TurboModule annotation issues - const originalGetConstants = TrackPlayerModule.getConstants; - TrackPlayerModule.getConstants = function () { - try { - return originalGetConstants.call(this); - } catch (e) { - console.warn( - "[TrackPlayerService] TurboModule getConstants error, returning safe defaults", - ); - return { - STATE_NONE: 0, - STATE_READY: 1, - STATE_PLAYING: 2, - STATE_PAUSED: 3, - STATE_STOPPED: 4, - STATE_BUFFERING: 5, - STATE_CONNECTING: 6, - }; - } - }; - - // Mark all methods as asynchronous to avoid TurboModule sync issues - const syncMethods = [ - "getConstants", - "getState", - "getPosition", - "getDuration", - "getBufferedPosition", - ]; - syncMethods.forEach((method) => { - if (typeof TrackPlayerModule[method] === "function") { - const originalMethod = TrackPlayerModule[method]; - TrackPlayerModule[method] = function (...args: any[]) { - try { - return originalMethod.apply(this, args); - } catch (e) { - console.warn( - `[TrackPlayerService] TurboModule ${method} error:`, - e, - ); - // Return safe defaults for sync methods - if (method === "getState") { - return Promise.resolve(0); - } - if (method === "getPosition") { - return Promise.resolve(0); - } - if (method === "getDuration") { - return Promise.resolve(0); - } - if (method === "getBufferedPosition") { - return Promise.resolve(0); - } - return Promise.resolve(0); - } - }; - } - }); - } - - console.log("[TrackPlayerService] TurboModule compatibility layer applied"); - } catch (error) { + } catch (e) { console.warn( - "[TrackPlayerService] Failed to apply TurboModule compatibility layer:", - error, + "[TrackPlayerService] Failed to setup TurboModule compatibility:", + e ); } -}; +} export class TrackPlayerService { private static instance: TrackPlayerService; private isSetup = false; + private setupPromise: Promise | null = null; private currentTrackIndex = 0; private playlist: Track[] = []; + public onError?: (error: any) => void; static getInstance(): TrackPlayerService { if (!TrackPlayerService.instance) { @@ -247,7 +117,7 @@ export class TrackPlayerService { } catch (error) { console.error( "[TrackPlayerService] Failed to create TrackPlayerService instance:", - error, + error ); throw error; } @@ -255,11 +125,15 @@ export class TrackPlayerService { return TrackPlayerService.instance; } + private constructor() { + // Private constructor for singleton pattern + } + private async ensureTrackPlayerReady(): Promise { // Check if TrackPlayer is available and initialized if (!TrackPlayer) { throw new Error( - "TrackPlayer is not available - make sure react-native-track-player is properly installed", + "TrackPlayer is not available - make sure react-native-track-player is properly installed" ); } @@ -270,7 +144,7 @@ export class TrackPlayerService { if (!nativeTrackPlayer) { throw new Error( - "Native TrackPlayer module is not available. If you are using Expo, make sure you are *not* running in Expo Go and that you have rebuilt the app after installing react-native-track-player.", + "Native TrackPlayer module is not available. If you are using Expo, make sure you are *not* running in Expo Go and that you have rebuilt the app after installing react-native-track-player." ); } @@ -287,7 +161,7 @@ export class TrackPlayerService { return originalGetConstants.call(this); } catch (e) { console.warn( - "[TrackPlayerService] TurboModule getConstants error, returning empty object", + "[TrackPlayerService] getConstants failed, returning empty object" ); return {}; } @@ -295,7 +169,7 @@ export class TrackPlayerService { } catch (e) { console.warn( "[TrackPlayerService] Failed to wrap native module methods:", - e, + e ); } } @@ -307,7 +181,7 @@ export class TrackPlayerService { console.log("[TrackPlayerService] TrackPlayer state check:", state); } catch (error) { console.warn( - "[TrackPlayerService] TrackPlayer not ready, attempting setup...", + "[TrackPlayerService] TrackPlayer not ready, attempting setup..." ); await this.setupPlayer(); } @@ -318,6 +192,22 @@ export class TrackPlayerService { return; } + // If setup is already in progress, wait for it to complete + if (this.setupPromise) { + return await this.setupPromise; + } + + // Create a new setup promise to prevent concurrent initialization + this.setupPromise = this.performSetup(); + + try { + await this.setupPromise; + } finally { + this.setupPromise = null; + } + } + + private async performSetup() { try { console.log("[TrackPlayerService] Setting up TrackPlayer..."); @@ -325,35 +215,35 @@ export class TrackPlayerService { if (!TrackPlayer) { console.error("[TrackPlayerService] TrackPlayer is null!"); throw new Error( - "TrackPlayer is not available - make sure react-native-track-player is properly installed", + "TrackPlayer is not available - make sure react-native-track-player is properly installed" ); } // Check if Capability constants are available if (!Capability) { console.warn( - "[TrackPlayerService] Capability constants are null, using string fallbacks", + "[TrackPlayerService] Capability constants are null, using string fallbacks" ); } else { console.log( "[TrackPlayerService] Capability constants available:", - Object.keys(Capability), + Object.keys(Capability) ); // Test individual capabilities to ensure they're not null try { const testCapabilities = [ - getSafeCapability("Play"), - getSafeCapability("Pause"), - getSafeCapability("SkipToNext"), - getSafeCapability("SkipToPrevious"), + getSafeCapability("Play" as keyof typeof Capability), + getSafeCapability("Pause" as keyof typeof Capability), + getSafeCapability("SkipToNext" as keyof typeof Capability), + getSafeCapability("SkipToPrevious" as keyof typeof Capability), ]; console.log( - "[TrackPlayerService] All capability fallbacks working correctly", + "[TrackPlayerService] All capability fallbacks working correctly" ); } catch (error) { console.error( "[TrackPlayerService] Error testing capability fallbacks:", - error, + error ); } } @@ -365,10 +255,10 @@ export class TrackPlayerService { if (!nativeTrackPlayer) { console.error( - "[TrackPlayerService] Native TrackPlayer module is null - this usually means the native module is not linked or you are running in an environment (like Expo Go or web) that does not support react-native-track-player.", + "[TrackPlayerService] Native TrackPlayer module is null - this usually means the native module is not linked or you are running in an environment (like Expo Go or web) that does not support react-native-track-player." ); throw new Error( - "Native TrackPlayer module is not available. Rebuild the app after installing react-native-track-player and avoid running in Expo Go.", + "Native TrackPlayer module is not available. Rebuild the app after installing react-native-track-player and avoid running in Expo Go." ); } @@ -379,39 +269,51 @@ export class TrackPlayerService { const constants = nativeTrackPlayer.getConstants(); console.log( "[TrackPlayerService] TrackPlayer constants available:", - !!constants, + !!constants ); } } catch (turboError) { console.warn( - "[TrackPlayerService] TurboModule compatibility issue detected, continuing with setup...", + "[TrackPlayerService] TurboModule compatibility issue detected, continuing with setup..." ); // Continue with setup even if there are TurboModule issues } console.log( "[TrackPlayerService] TrackPlayer object type:", - typeof TrackPlayer, + typeof TrackPlayer ); console.log( "[TrackPlayerService] TrackPlayer methods:", - Object.keys(TrackPlayer), + Object.getOwnPropertyNames(TrackPlayer) + .filter((name) => typeof (TrackPlayer as any)[name] === "function") + .slice(0, 10) ); // Add a small delay to ensure native module is ready during development reload await new Promise((resolve) => setTimeout(resolve, 100)); await TrackPlayer.setupPlayer({ - maxCacheSize: 1024 * 10, // 10MB cache + maxCacheSize: 1024 * 50, // 50MB cache for better streaming iosCategory: IOSCategory.Playback, iosCategoryMode: IOSCategoryMode.Default, iosCategoryOptions: [ IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetoothA2DP, ], + // Better buffering for streaming - more conservative for YouTube + minBuffer: 10, // 10 seconds minimum buffer (reduced from 15) + maxBuffer: 30, // 30 seconds maximum buffer (reduced from 60) + playBuffer: 2, // 2 seconds play buffer (reduced from 5) + backBuffer: 5, // 5 seconds back buffer (reduced from 10) + // Additional streaming options + waitForBuffer: true, // Wait for buffer before playing + autoUpdateMetadata: false, // Don't auto-update metadata for streams + // Enable automatic interruption handling + autoHandleInterruptions: false, }); console.log( - "[TrackPlayerService] TrackPlayer setup completed successfully", + "[TrackPlayerService] TrackPlayer setup completed successfully" ); // Build capabilities array safely @@ -428,23 +330,23 @@ export class TrackPlayerService { TrackPlayerCapability.SkipToNext, TrackPlayerCapability.SkipToPrevious, TrackPlayerCapability.Stop, - TrackPlayerCapability.SeekTo, + TrackPlayerCapability.SeekTo ); compactCapabilities.push( TrackPlayerCapability.Play, TrackPlayerCapability.Pause, TrackPlayerCapability.SkipToNext, - TrackPlayerCapability.SkipToPrevious, + TrackPlayerCapability.SkipToPrevious ); notificationCapabilities.push( TrackPlayerCapability.Play, TrackPlayerCapability.Pause, TrackPlayerCapability.SkipToNext, - TrackPlayerCapability.SkipToPrevious, + TrackPlayerCapability.SkipToPrevious ); } catch (error) { console.warn( - "[TrackPlayerService] Error accessing capability constants, using fallbacks", + "[TrackPlayerService] Error accessing capability constants, using fallbacks" ); } } @@ -457,19 +359,19 @@ export class TrackPlayerService { "skipToNext", "skipToPrevious", "stop", - "seekTo", + "seekTo" ); compactCapabilities.push( "play", "pause", "skipToNext", - "skipToPrevious", + "skipToPrevious" ); notificationCapabilities.push( "play", "pause", "skipToNext", - "skipToPrevious", + "skipToPrevious" ); } @@ -496,61 +398,180 @@ export class TrackPlayerService { } private setupEventListeners() { - TrackPlayer.addEventListener(getSafeEvent("RemotePlay"), () => { - TrackPlayer.play(); - }); + TrackPlayer.addEventListener( + getSafeEvent("RemotePlay" as keyof typeof Event), + () => { + TrackPlayer.play(); + } + ); + + TrackPlayer.addEventListener( + getSafeEvent("RemotePause" as keyof typeof Event), + () => { + TrackPlayer.pause(); + } + ); - TrackPlayer.addEventListener(getSafeEvent("RemotePause"), () => { - TrackPlayer.pause(); - }); + TrackPlayer.addEventListener( + getSafeEvent("RemoteStop" as keyof typeof Event), + () => { + TrackPlayer.stop(); + } + ); - TrackPlayer.addEventListener(getSafeEvent("RemoteNext"), () => { - TrackPlayer.skipToNext(); - }); + TrackPlayer.addEventListener( + getSafeEvent("RemoteNext" as keyof typeof Event), + () => { + this.skipToNext(); + } + ); - TrackPlayer.addEventListener(getSafeEvent("RemotePrevious"), () => { - TrackPlayer.skipToPrevious(); - }); + TrackPlayer.addEventListener( + getSafeEvent("RemotePrevious" as keyof typeof Event), + () => { + this.skipToPrevious(); + } + ); - TrackPlayer.addEventListener(getSafeEvent("RemoteStop"), () => { - TrackPlayer.stop(); - }); + TrackPlayer.addEventListener( + getSafeEvent("RemoteSeek" as keyof typeof Event), + (event: any) => { + TrackPlayer.seekTo(event.position); + } + ); - TrackPlayer.addEventListener(getSafeEvent("RemoteSeek"), (event: any) => { - TrackPlayer.seekTo(event.position); - }); + TrackPlayer.addEventListener( + getSafeEvent("PlaybackQueueEnded" as keyof typeof Event), + (event: any) => { + console.log("[TrackPlayerService] Playback queue ended:", event); + // Handle queue end - could repeat playlist or stop + } + ); - TrackPlayer.addEventListener(getSafeEvent("PlaybackQueueEnded"), () => { - console.log("[TrackPlayerService] Playback queue ended"); - }); + TrackPlayer.addEventListener( + getSafeEvent("PlaybackTrackChanged" as keyof typeof Event), + (event: any) => { + console.log("[TrackPlayerService] Playback track changed:", event); + this.currentTrackIndex = event.nextTrack; + } + ); TrackPlayer.addEventListener( - getSafeEvent("PlaybackTrackChanged"), + getSafeEvent("PlaybackError" as keyof typeof Event), (event: any) => { - console.log( - "[TrackPlayerService] Track changed to index:", - event.nextTrack, - ); - this.currentTrackIndex = event.nextTrack || 0; - }, + console.error("[TrackPlayerService] Playback error:", event); + + // Enhanced error logging for YouTube streams + const currentTrack = this.playlist[this.currentTrackIndex]; + if ( + currentTrack && + currentTrack.audioUrl && + (currentTrack.audioUrl.includes("googlevideo.com") || + currentTrack.audioUrl.includes("youtube.com")) + ) { + console.error( + `[TrackPlayerService] YouTube stream error for track: ${currentTrack.title}` + ); + console.error( + `[TrackPlayerService] YouTube URL: ${currentTrack.audioUrl.substring(0, 100)}...` + ); + + // Check for specific YouTube error patterns + if (event?.message?.includes("403") || event?.code === 403) { + console.error( + "[TrackPlayerService] YouTube URL expired (403 Forbidden) - needs refresh" + ); + } else if (event?.message?.includes("404") || event?.code === 404) { + console.error( + "[TrackPlayerService] YouTube URL not found (404) - stream may be removed" + ); + } else if ( + event?.message?.includes("Network") || + event?.message?.includes("Connection") + ) { + console.error( + "[TrackPlayerService] Network error during YouTube playback" + ); + } + } + + // Handle playback errors - notify the PlayerContext to attempt stream recovery + if (this.onError) { + this.onError(event); + } + } ); } - convertTrackToTrackPlayer(track: Track, index: number) { + private async validateYouTubeUrl(url: string): Promise { + if (!url) { + console.warn("[TrackPlayerService] YouTube URL validation: empty URL"); + return false; + } + // Assume non-empty googlevideo/youtube URLs are usable; rely on playback errors for real failures + return true; + } + + private convertTrackToTrackPlayer( + track: Track, + index: number + ): TrackPlayerTrack { + const headers: { [key: string]: string } = {}; + const url = track.audioUrl || ""; + + // Validate URL - throw error if empty to prevent TrackPlayer from failing + if (!url) { + throw new Error(`Track ${track.title} (${track.id}) has no audio URL`); + } + + const isYouTubeStream = + track.source === "youtube" || + url.includes("googlevideo.com") || + url.includes("youtube.com"); + + if (isYouTubeStream) { + Object.assign(headers, { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + Referer: "https://www.youtube.com/", + Origin: "https://www.youtube.com/", + }); + } + + // Add JioSaavn-specific headers if needed + if (track._isJioSaavn) { + Object.assign(headers, { + "User-Agent": "JioSaavn/1.0", + Accept: "audio/*", + }); + } + + let contentType: string | undefined; + if (isYouTubeStream && url) { + const mimeMatch = url.match(/[?&]mime=([^&]+)/); + if (mimeMatch && mimeMatch[1]) { + try { + contentType = decodeURIComponent(mimeMatch[1]); + } catch { + contentType = mimeMatch[1]; + } + } + } + return { id: track.id, - url: track.audioUrl || "", + url, title: track.title, artist: track.artist || t("screens.artist.unknown_artist"), + album: "Streamify", // Add album for better notification display artwork: track.thumbnail || "", duration: track.duration || 0, - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - }, - // Store original track data for internal use + headers: headers, + userAgent: isYouTubeStream + ? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + : undefined, + contentType, pitchAlgorithm: PitchAlgorithm.Linear, - // Add custom metadata ...(track.source && { source: track.source }), ...(track._isSoundCloud && { _isSoundCloud: track._isSoundCloud }), ...(track._isJioSaavn && { _isJioSaavn: track._isJioSaavn }), @@ -561,18 +582,67 @@ export class TrackPlayerService { try { console.log( "[TrackPlayerService] addTracks called, isSetup:", - this.isSetup, + this.isSetup ); // Ensure player is initialized and ready before adding tracks await this.ensureTrackPlayerReady(); console.log( - "[TrackPlayerService] Player setup complete, proceeding with addTracks", + "[TrackPlayerService] Player setup complete, proceeding with addTracks" ); - const trackPlayerTracks = tracks.map((track, index) => - this.convertTrackToTrackPlayer(track, index), + // Validate YouTube URLs before adding tracks + console.log("[TrackPlayerService] Validating YouTube URLs..."); + const validatedTracks = await Promise.all( + tracks.map(async (track) => { + if ( + track.audioUrl && + (track.source === "youtube" || + track.audioUrl.includes("googlevideo.com")) + ) { + const isValid = await this.validateYouTubeUrl(track.audioUrl); + if (!isValid) { + console.warn( + `[TrackPlayerService] YouTube URL validation failed for track: ${track.title}` + ); + return { + ...track, + audioUrl: undefined, // Mark as invalid + }; + } + } + return track; + }) + ); + + const playableTracks: Track[] = []; + const playableIndexMap: number[] = []; + + validatedTracks.forEach((track, index) => { + if (track.audioUrl) { + playableTracks.push(track); + playableIndexMap.push(index); + } else { + console.warn( + `[TrackPlayerService] Skipping track without audio URL: ${track.title} (${track.id})` + ); + } + }); + + if (playableTracks.length === 0) { + throw new Error( + "[TrackPlayerService] No playable tracks with audioUrl to add" + ); + } + + let adjustedStartIndex = playableIndexMap.indexOf(startIndex); + if (adjustedStartIndex === -1) { + adjustedStartIndex = 0; + } + + const trackPlayerTracks = playableTracks.map((track, index) => + this.convertTrackToTrackPlayer(track, index) ); console.log("[TrackPlayerService] About to call TrackPlayer.reset()"); @@ -580,31 +650,32 @@ export class TrackPlayerService { try { await TrackPlayer.reset(); console.log( - "[TrackPlayerService] TrackPlayer.reset() completed successfully", + "[TrackPlayerService] TrackPlayer.reset() completed successfully" ); } catch (resetError) { console.error( "[TrackPlayerService] TrackPlayer.reset() failed:", - resetError, + resetError ); - console.error("[TrackPlayerService] TrackPlayer object:", TrackPlayer); - throw resetError; + // Continue even if reset fails } + // Add tracks to the player + console.log("[TrackPlayerService] Adding tracks to TrackPlayer..."); await TrackPlayer.add(trackPlayerTracks); - this.playlist = tracks; - this.currentTrackIndex = startIndex; + console.log("[TrackPlayerService] Tracks added successfully"); + + this.playlist = [...playableTracks]; + this.currentTrackIndex = adjustedStartIndex; - if (startIndex > 0) { - await TrackPlayer.skip(startIndex); + if (adjustedStartIndex > 0) { + console.log( + `[TrackPlayerService] Skipping to track index: ${adjustedStartIndex}` + ); + await TrackPlayer.skip(adjustedStartIndex); } - console.log( - "[TrackPlayerService] Added", - tracks.length, - "tracks starting at index", - startIndex, - ); + console.log("[TrackPlayerService] addTracks completed successfully"); } catch (error) { console.error("[TrackPlayerService] Failed to add tracks:", error); throw error; @@ -614,6 +685,34 @@ export class TrackPlayerService { async play() { try { await this.ensureTrackPlayerReady(); + + // Get current track to check if it's a YouTube stream + const currentTrackIndex = await TrackPlayer.getCurrentTrack(); + if (currentTrackIndex !== null && this.playlist[currentTrackIndex]) { + const currentTrack = this.playlist[currentTrackIndex]; + const isYouTubeStream = + currentTrack.audioUrl && + (currentTrack.audioUrl.includes("googlevideo.com") || + currentTrack.audioUrl.includes("youtube.com")); + + if (isYouTubeStream) { + console.log( + "[TrackPlayerService] YouTube stream detected, adding safety delay..." + ); + // Add a small delay for YouTube streams to initialize properly + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Validate the YouTube URL before playing + const isValid = await this.validateYouTubeUrl(currentTrack.audioUrl); + if (!isValid) { + console.error( + "[TrackPlayerService] YouTube URL validation failed before playback" + ); + throw new Error("YouTube stream URL is no longer valid"); + } + } + } + await TrackPlayer.play(); console.log("[TrackPlayerService] Playback started"); } catch (error) { @@ -656,11 +755,8 @@ export class TrackPlayerService { async skipToNext() { try { + await this.ensureTrackPlayerReady(); await TrackPlayer.skipToNext(); - this.currentTrackIndex = Math.min( - this.currentTrackIndex + 1, - this.playlist.length - 1, - ); console.log("[TrackPlayerService] Skipped to next track"); } catch (error) { console.error("[TrackPlayerService] Failed to skip to next:", error); @@ -670,8 +766,8 @@ export class TrackPlayerService { async skipToPrevious() { try { + await this.ensureTrackPlayerReady(); await TrackPlayer.skipToPrevious(); - this.currentTrackIndex = Math.max(this.currentTrackIndex - 1, 0); console.log("[TrackPlayerService] Skipped to previous track"); } catch (error) { console.error("[TrackPlayerService] Failed to skip to previous:", error); @@ -681,158 +777,189 @@ export class TrackPlayerService { async skipToTrack(index: number) { try { + await this.ensureTrackPlayerReady(); await TrackPlayer.skip(index); this.currentTrackIndex = index; - console.log("[TrackPlayerService] Skipped to track index:", index); + console.log(`[TrackPlayerService] Skipped to track index: ${index}`); } catch (error) { console.error("[TrackPlayerService] Failed to skip to track:", error); throw error; } } - async getCurrentTrack() { + async getCurrentTrack(): Promise { try { - const track = await TrackPlayer.getActiveTrack(); - return track; + const currentTrackIndex = await TrackPlayer.getCurrentTrack(); + if ( + currentTrackIndex !== null && + currentTrackIndex < this.playlist.length + ) { + return this.playlist[currentTrackIndex]; + } + return null; } catch (error) { console.error("[TrackPlayerService] Failed to get current track:", error); return null; } } - async getPosition() { + async updateCurrentTrack(audioUrl: string): Promise { try { - const position = await TrackPlayer.getPosition(); - return position; + const currentTrackIndex = await TrackPlayer.getCurrentTrack(); + if ( + currentTrackIndex !== null && + currentTrackIndex < this.playlist.length + ) { + // Validate YouTube URL before updating + const isYouTubeStream = + audioUrl && + (audioUrl.includes("googlevideo.com") || + audioUrl.includes("youtube.com")); + + if (isYouTubeStream) { + console.log( + "[TrackPlayerService] Validating YouTube URL before track update..." + ); + const isValid = await this.validateYouTubeUrl(audioUrl); + if (!isValid) { + throw new Error( + "Cannot update track: YouTube URL is no longer valid" + ); + } + console.log("[TrackPlayerService] YouTube URL validation passed"); + } + + // Update the current track's audio URL + const updatedTrack = { + ...this.playlist[currentTrackIndex], + audioUrl, + }; + this.playlist[currentTrackIndex] = updatedTrack; + + // Get current position before updating + const currentPosition = await this.getPosition(); + + // Remove current track and re-add with new URL + await TrackPlayer.remove(currentTrackIndex); + await TrackPlayer.add([ + this.convertTrackToTrackPlayer(updatedTrack, currentTrackIndex), + ]); + + // Seek back to previous position + await TrackPlayer.seekTo(currentPosition); + + console.log( + "[TrackPlayerService] Updated current track with new audio URL" + ); + } } catch (error) { - console.error("[TrackPlayerService] Failed to get position:", error); - return 0; + console.error( + "[TrackPlayerService] Failed to update current track:", + error + ); + throw error; } } - async getDuration() { + async reset(): Promise { try { - const duration = await TrackPlayer.getDuration(); - return duration; + // Stop playback + await this.stop(); + + // Clear the playlist + this.playlist = []; + this.currentTrackIndex = 0; + + // Remove all tracks from the queue + const queue = await TrackPlayer.getQueue(); + if (queue.length > 0) { + await TrackPlayer.remove([...Array(queue.length).keys()]); + } + + console.log("[TrackPlayerService] Reset completed"); } catch (error) { - console.error("[TrackPlayerService] Failed to get duration:", error); + console.error("[TrackPlayerService] Failed to reset:", error); + throw error; + } + } + + async getPosition(): Promise { + try { + return await TrackPlayer.getPosition(); + } catch (error) { + console.error("[TrackPlayerService] Failed to get position:", error); return 0; } } - async getPlaybackState() { + async getDuration(): Promise { try { - const state = await TrackPlayer.getPlaybackState(); - return state; + return await TrackPlayer.getDuration(); } catch (error) { - console.error( - "[TrackPlayerService] Failed to get playback state:", - error, - ); - return { state: State.None }; + console.error("[TrackPlayerService] Failed to get duration:", error); + return 0; } } - async isPlaying() { + async getState(): Promise { try { - const state = await this.getPlaybackState(); - return state.state === State.Playing; + return await TrackPlayer.getState(); } catch (error) { - console.error("[TrackPlayerService] Failed to check if playing:", error); - return false; + console.error("[TrackPlayerService] Failed to get state:", error); + return State.None; } } - async setVolume(volume: number) { + async setRepeatMode(mode: RepeatMode): Promise { try { - await TrackPlayer.setVolume(volume); - console.log("[TrackPlayerService] Volume set to:", volume); + await TrackPlayer.setRepeatMode(mode); + console.log(`[TrackPlayerService] Set repeat mode to: ${mode}`); } catch (error) { - console.error("[TrackPlayerService] Failed to set volume:", error); + console.error("[TrackPlayerService] Failed to set repeat mode:", error); throw error; } } - async getVolume() { + async getRepeatMode(): Promise { try { - const volume = await TrackPlayer.getVolume(); - return volume; + return await TrackPlayer.getRepeatMode(); } catch (error) { - console.error("[TrackPlayerService] Failed to get volume:", error); - return 1; + console.error("[TrackPlayerService] Failed to get repeat mode:", error); + return RepeatMode.Off; } } - async setRepeatMode(mode: "off" | "one" | "all") { + async getQueue(): Promise { try { - let repeatMode: any; - switch (mode) { - case "one": - repeatMode = RepeatMode.Track; - break; - case "all": - repeatMode = RepeatMode.Queue; - break; - default: - repeatMode = RepeatMode.Off; - } - await TrackPlayer.setRepeatMode(repeatMode); - console.log("[TrackPlayerService] Repeat mode set to:", mode); + return await TrackPlayer.getQueue(); } catch (error) { - console.error("[TrackPlayerService] Failed to set repeat mode:", error); - throw error; + console.error("[TrackPlayerService] Failed to get queue:", error); + return []; } } - async reset() { + async removeUpcomingTracks(): Promise { try { - await TrackPlayer.reset(); - this.playlist = []; - this.currentTrackIndex = 0; - console.log("[TrackPlayerService] Player reset"); + await TrackPlayer.removeUpcomingTracks(); + console.log("[TrackPlayerService] Removed upcoming tracks"); } catch (error) { - console.error("[TrackPlayerService] Failed to reset:", error); + console.error( + "[TrackPlayerService] Failed to remove upcoming tracks:", + error + ); throw error; } } - getCurrentTrackIndex() { - return this.currentTrackIndex; - } - - getPlaylist() { - return this.playlist; - } - - async updateCurrentTrack(newAudioUrl: string) { + async destroy(): Promise { try { - const currentTrack = await this.getCurrentTrack(); - if (!currentTrack) { - console.error("[TrackPlayerService] No current track to update"); - return; - } - - // Update the current track's URL - const updatedTrack = { - ...currentTrack, - url: newAudioUrl, - }; - - // Remove current track and add updated one - await TrackPlayer.remove(this.currentTrackIndex); - await TrackPlayer.add([updatedTrack], this.currentTrackIndex); - - // Skip to the updated track - await TrackPlayer.skip(this.currentTrackIndex); - - console.log( - "[TrackPlayerService] Updated current track with new URL:", - newAudioUrl, - ); + await TrackPlayer.reset(); + this.isSetup = false; + console.log("[TrackPlayerService] TrackPlayer destroyed"); } catch (error) { console.error( - "[TrackPlayerService] Failed to update current track:", - error, + "[TrackPlayerService] Failed to destroy TrackPlayer:", + error ); throw error; } diff --git a/test_instances.js b/test_instances.js new file mode 100644 index 0000000..4353915 --- /dev/null +++ b/test_instances.js @@ -0,0 +1,5 @@ +// Test script to check if dynamic instances are loaded +import { DYNAMIC_INVIDIOUS_INSTANCES } from './components/core/api'; + +console.log('Dynamic instances:', DYNAMIC_INVIDIOUS_INSTANCES); +console.log('Number of instances:', DYNAMIC_INVIDIOUS_INSTANCES.length); \ No newline at end of file diff --git a/test_invidious.js b/test_invidious.js new file mode 100644 index 0000000..bd12ecf --- /dev/null +++ b/test_invidious.js @@ -0,0 +1,50 @@ +const { DYNAMIC_INVIDIOUS_INSTANCES } = require("./components/core/api.ts"); + +async function testInvidiousInstances() { + console.log("Testing Invidious instances..."); + console.log("Available instances:", DYNAMIC_INVIDIOUS_INSTANCES); + + const testVideoId = "tfSS1e3kYeo"; // Travis Scott - HIGHEST IN THE ROOM + + for (const instance of DYNAMIC_INVIDIOUS_INSTANCES) { + try { + console.log(`Testing instance: ${instance}`); + const response = await fetch( + `${instance}/api/v1/videos/${testVideoId}?local=true` + ); + + if (!response.ok) { + console.log(` ❌ Failed: ${response.status}`); + continue; + } + + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("json")) { + console.log(` ❌ Blocked: HTML response`); + continue; + } + + const data = await response.json(); + if (data.adaptiveFormats || data.formatStreams) { + console.log(` ✅ Working: Found audio formats`); + const audioFormats = + data.adaptiveFormats?.filter( + (f) => + f.type?.startsWith("audio/") || f.mimeType?.startsWith("audio/") + ) || []; + console.log(` Audio formats: ${audioFormats.length}`); + if (audioFormats.length > 0) { + console.log( + ` First format URL: ${audioFormats[0].url?.substring(0, 100)}...` + ); + } + } else { + console.log(` ❌ No audio formats found`); + } + } catch (error) { + console.log(` ❌ Error: ${error.message}`); + } + } +} + +testInvidiousInstances(); diff --git a/utils/imageColors.ts b/utils/imageColors.ts index 1691771..bb9d2ce 100644 --- a/utils/imageColors.ts +++ b/utils/imageColors.ts @@ -26,18 +26,12 @@ export const extractColorsFromImage = async ( if (imageUrl) { const colors = await extractDominantColors(imageUrl); - console.log("[extractColorsFromImage] Extracted colors:", colors); // eslint-disable-line no-console if (colors && colors.length > 0) { const primaryColor = colors[0] || defaultTheme.primary; const secondaryColor = colors[1] || defaultTheme.secondary; const accentColor = colors[2] || defaultTheme.accent; - console.log("[extractColorsFromImage] Using colors:", { - primaryColor, - secondaryColor, - accentColor, - }); - + // Find the closest matching predefined theme based on extracted colors const closestTheme = findClosestPredefinedTheme( primaryColor, @@ -46,10 +40,7 @@ export const extractColorsFromImage = async ( ); if (closestTheme) { - console.log( - "[extractColorsFromImage] Found closest theme:", - closestTheme, - ); + return closestTheme; } @@ -968,19 +959,11 @@ const findClosestPredefinedTheme = ( // Only return a predefined theme if it's reasonably close (threshold: 150) if (minDistance < 150) { - console.log( - "[findClosestPredefinedTheme] Found theme with distance:", - minDistance, - "Theme:", - closestTheme, - ); + return closestTheme; } - console.log( - "[findClosestPredefinedTheme] No close theme found, minDistance:", - minDistance, - ); + return null; }; From d5b751125a9e151f6a91db28a468c9c74e0a69c3 Mon Sep 17 00:00:00 2001 From: Erfan Date: Mon, 9 Feb 2026 12:06:29 +0330 Subject: [PATCH 7/7] feat: add SoundCloud playlist support and improve UI responsiveness - Add SoundCloud playlist loading in AlbumPlaylistScreen - Enable JioSaavn search functionality in SearchScreen - Improve playlist title/artist text truncation with flex-shrink - Replace FlatList with map for better search performance - Enhance audio streaming with direct JioSaavn URL fetching - Fix seek slider touch target and position synchronization - Update Invidious instance management with health checks - Remove redundant SoundCloud suggestion API calls --- components/FullPlayerModal.tsx | 96 ++++-- components/Playlist.tsx | 14 +- components/core/api.ts | 105 ++++++- components/screens/AlbumPlaylistScreen.tsx | 65 ++++ components/screens/SearchScreen.tsx | 114 ++++--- contexts/PlayerContext.tsx | 176 ++++------- modules/audioStreaming.ts | 340 ++++---------------- modules/searchAPI.ts | 343 +++++++++++---------- 8 files changed, 598 insertions(+), 655 deletions(-) diff --git a/components/FullPlayerModal.tsx b/components/FullPlayerModal.tsx index 66b282e..d5a881f 100644 --- a/components/FullPlayerModal.tsx +++ b/components/FullPlayerModal.tsx @@ -373,7 +373,7 @@ const ProgressContainer = styled.View` const ProgressBarContainer = styled.View` width: 100%; - height: 4px; + height: 40px; /* Increased height for better touch target */ justify-content: center; `; @@ -382,7 +382,10 @@ const ProgressSlider = React.forwardRef((props, ref) => { ); }); @@ -396,9 +399,10 @@ const TimeContainer = styled.View` `; const TimeText = styled.Text` - color: #999; - font-size: 12px; + color: #ffffff; + font-size: 14px; font-family: GoogleSansRegular; + font-weight: 500; `; const Controls = styled.View` @@ -549,6 +553,9 @@ export const FullPlayerModal: React.FC = ({ duration, } = usePlayer(); + const [isSeeking, setIsSeeking] = useState(false); + const [seekValue, setSeekValue] = useState(0); + const [cacheInfo, setCacheInfo] = useState<{ percentage: number; fileSize: number; @@ -566,7 +573,7 @@ export const FullPlayerModal: React.FC = ({ const [lyricsError, setLyricsError] = useState(null); const [isOptionsVisible, setIsOptionsVisible] = useState(false); const [sheetState, setSheetState] = useState<"closed" | "half" | "full">( - "closed", + "closed" ); const [showPlaylistSelection, setShowPlaylistSelection] = useState(false); const [userPlaylists, setUserPlaylists] = useState([]); @@ -574,6 +581,19 @@ export const FullPlayerModal: React.FC = ({ const [sheetHeight, setSheetHeight] = useState(SHEET_HEIGHT); const sheetStateRef = useRef<"closed" | "half" | "full">("closed"); + const totalDurationSeconds = + duration > 0 ? duration : currentTrack?.duration || 0; + + useEffect(() => { + if (!isSeeking) { + const clampedPosition = + totalDurationSeconds > 0 + ? Math.min(position, totalDurationSeconds) + : position; + setSeekValue(clampedPosition * 1000); + } + }, [position, isSeeking, totalDurationSeconds]); + const animateSheet = (state: "closed" | "half" | "full") => { let toValue = SHEET_CLOSED_TOP; if (state === "closed") { @@ -643,7 +663,7 @@ export const FullPlayerModal: React.FC = ({ animateSheet(target); }, - }), + }) ).current; const openOptions = () => { @@ -674,7 +694,7 @@ export const FullPlayerModal: React.FC = ({ try { // Check if song is already in playlist const isAlreadyInPlaylist = playlist.tracks.some( - (track) => track.id === currentTrack.id, + (track) => track.id === currentTrack.id ); if (isAlreadyInPlaylist) { @@ -741,7 +761,7 @@ export const FullPlayerModal: React.FC = ({ console.log("[FullPlayerModal] Starting lyrics fetch..."); const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Lyrics fetch timeout")), 15000), + setTimeout(() => reject(new Error("Lyrics fetch timeout")), 15000) ); try { @@ -779,7 +799,7 @@ export const FullPlayerModal: React.FC = ({ } catch (error) { console.error( "[FullPlayerModal] Error or timeout fetching lyrics:", - error, + error ); setLyricsData([]); setCurrentLyricIndex(0); @@ -807,7 +827,7 @@ export const FullPlayerModal: React.FC = ({ fileSize: 0, totalFileSize: 0, isFullyCached: false, - }, + } ); } }, [cacheProgress, currentTrack?.id]); @@ -839,16 +859,19 @@ export const FullPlayerModal: React.FC = ({ }, [currentTrack?.audioUrl, currentTrack?.id, getCacheInfo]); const handleSeek = async (value: number) => { - try { - if (currentTrack?.audioUrl) { - // Convert milliseconds to seconds for seekTo - await seekTo(value / 1000); - } - // Position is now managed by PlayerContext via PlaybackProgressUpdated events - } catch (error) { - // Silently ignore seek errors - // Position is now managed by PlayerContext via PlaybackProgressUpdated events + console.log(`[FullPlayerModal] handleSeek called with value: ${value}`); + if (!currentTrack?.audioUrl) { + console.log("[FullPlayerModal] Cannot seek - no audio URL"); + setIsSeeking(false); + return; } + + setIsSeeking(false); + console.log( + `[FullPlayerModal] Seeking to position: ${value / 1000} seconds` + ); + await seekTo(value / 1000); + console.log("[FullPlayerModal] Seek completed"); }; const handlePlayPause = async () => { @@ -918,7 +941,10 @@ export const FullPlayerModal: React.FC = ({ {/* Content with ScrollView for full screen scrollability */} - + {currentTrack.thumbnail ? ( = ({ { + setIsSeeking(true); + setSeekValue( + Math.min( + seekValue, + Math.max(totalDurationSeconds * 1000, 1) + ) + ); + }} + onValueChange={(value) => { + console.log( + `[FullPlayerModal] Slider onValueChange: ${value}` + ); + setSeekValue(value); + }} + // @ts-ignore - TypeScript definitions don't match implementation + onSlidingComplete={(value) => { + handleSeek(value); + }} /> {formatTime(position * 1000)} - {formatTime(duration * 1000)} + {formatTime(totalDurationSeconds * 1000)} diff --git a/components/Playlist.tsx b/components/Playlist.tsx index b29bfe2..f566a4b 100644 --- a/components/Playlist.tsx +++ b/components/Playlist.tsx @@ -130,6 +130,7 @@ const AlbumTitle = styled.Text` font-size: 24px; font-family: GoogleSansBold; line-height: 28px; + flex-shrink: 1; `; const AlbumArtist = styled.Text` @@ -137,6 +138,7 @@ const AlbumArtist = styled.Text` font-size: 16px; font-family: GoogleSansRegular; line-height: 20px; + flex-shrink: 1; `; const ShuffleButton = styled.TouchableOpacity` @@ -303,9 +305,15 @@ export const Playlist: React.FC = ({ - - {title} - {artist && {artist}} + + + {title} + + {artist && ( + + {artist} + + )} diff --git a/components/core/api.ts b/components/core/api.ts index d2a8179..ff8cf09 100644 --- a/components/core/api.ts +++ b/components/core/api.ts @@ -29,18 +29,35 @@ export type InvidiousInstance = (typeof API.invidious)[number]; export type PipedInstance = (typeof API.piped)[number]; // Dynamic Invidious instances array (mutable) -export let DYNAMIC_INVIDIOUS_INSTANCES = [...API.invidious]; +export let DYNAMIC_INVIDIOUS_INSTANCES: string[] = [...API.invidious]; // Update function for dynamic Invidious instances -export function updateInvidiousInstances( - newInstances: readonly InvidiousInstance[] -) { +export function updateInvidiousInstances(newInstances: readonly string[]) { + const normalizedExisting = DYNAMIC_INVIDIOUS_INSTANCES.map((instance) => + normalizeInvidiousInstance(instance) + ); + const normalizedNew = newInstances.map((instance) => + normalizeInvidiousInstance(instance) + ); const uniqueInstances = [ - ...new Set([...DYNAMIC_INVIDIOUS_INSTANCES, ...newInstances]), + ...new Set([...normalizedExisting, ...normalizedNew]), ]; DYNAMIC_INVIDIOUS_INSTANCES = uniqueInstances; return uniqueInstances; } +export function setInvidiousInstances(instances: readonly string[]) { + DYNAMIC_INVIDIOUS_INSTANCES = [ + ...new Set( + instances.map((instance) => normalizeInvidiousInstance(instance)) + ), + ]; + return DYNAMIC_INVIDIOUS_INSTANCES; +} +export function normalizeInvidiousInstance(instance: string): string { + const trimmed = instance.trim(); + const withoutApi = trimmed.replace(/\/api\/v1\/?$/i, ""); + return withoutApi.replace(/\/+$/g, ""); +} // Helper functions for instance management export const idFromURL = (link: string | null) => @@ -101,7 +118,11 @@ export async function updateInvidiousInstancesFromUma(): Promise { try { const umaInstances = await fetchUma(); if (umaInstances.length > 0) { - updateInvidiousInstances(umaInstances as InvidiousInstance[]); + updateInvidiousInstances( + umaInstances.map((instance) => + normalizeInvidiousInstance(instance) + ) as string[] + ); console.log( `[API] Updated Invidious instances from Uma. Total: ${DYNAMIC_INVIDIOUS_INSTANCES.length}` ); @@ -233,6 +254,56 @@ export async function getHealthyInstances( .map((result) => result.instance); } +async function fastCheckInvidiousInstance( + baseUrl: string, + timeoutMs: number = 3000 +): Promise<{ instance: string; ok: boolean; latency: number }> { + const start = Date.now(); + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + const normalizedBaseUrl = normalizeInvidiousInstance(baseUrl); + const url = `${normalizedBaseUrl}/api/v1/videos/dQw4w9WgXcQ?local=true`; + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(t); + if (!res.ok) { + return { + instance: normalizedBaseUrl, + ok: false, + latency: Date.now() - start, + }; + } + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("json")) { + return { instance: baseUrl, ok: false, latency: Date.now() - start }; + } + const text = await res.text(); + const ok = + text.includes("adaptiveFormats") || + text.includes("formatStreams") || + text.includes("lengthSeconds"); + return { instance: normalizedBaseUrl, ok, latency: Date.now() - start }; + } catch { + return { + instance: normalizeInvidiousInstance(baseUrl), + ok: false, + latency: Date.now() - start, + }; + } +} + +export async function getHealthyInvidiousInstancesSorted( + instances: string[], + timeoutMs: number = 3000 +): Promise { + const checks = await Promise.all( + instances.map((i) => fastCheckInvidiousInstance(i, timeoutMs)) + ); + const healthy = checks.filter((c) => c.ok); + healthy.sort((a, b) => a.latency - b.latency); + return healthy.map((c) => c.instance); +} + // Original streaming functions export async function fetchStreamFromPiped(id: string, api: string) { const res = await fetch(`${api}/streams/${id}`); @@ -299,18 +370,22 @@ export async function initializeDynamicInstances(): Promise { const umaInstances = await fetchUma(); if (umaInstances.length > 0) { - // Add /api/v1 suffix to instances if not present - const formattedInstances = umaInstances.map((instance) => { - if (!instance.includes("/api/v1")) { - return `${instance}/api/v1`; - } - return instance; - }) as InvidiousInstance[]; + const formattedInstances = umaInstances.map((instance) => + normalizeInvidiousInstance(instance) + ) as string[]; updateInvidiousInstances(formattedInstances); - console.log( - `[API] Updated with ${formattedInstances.length} dynamic instances from Uma` + const healthy = await getHealthyInvidiousInstancesSorted( + DYNAMIC_INVIDIOUS_INSTANCES ); + if (healthy.length > 0) { + setInvidiousInstances(healthy as string[]); + console.log( + `[API] Healthy Invidious instances ready: ${healthy.length}` + ); + } else { + console.log("[API] No healthy instances detected, keeping defaults"); + } } else { console.log("[API] No dynamic instances fetched, using defaults"); } diff --git a/components/screens/AlbumPlaylistScreen.tsx b/components/screens/AlbumPlaylistScreen.tsx index 12dd539..8836679 100644 --- a/components/screens/AlbumPlaylistScreen.tsx +++ b/components/screens/AlbumPlaylistScreen.tsx @@ -374,6 +374,71 @@ export const AlbumPlaylistScreen: React.FC = ({ setAlbumArtist(routeArtist || "Unknown Artist"); setErrorMessage("Failed to load playlist"); } + } else if (source === "soundcloud") { + try { + const playlistUrlParam = + typeof albumId === "string" ? albumId : String(albumId); + const endpoint = `https://beatseek.io/api/playlist?url=${encodeURIComponent( + playlistUrlParam + )}`; + const res = await fetch(endpoint, { + headers: { + Accept: "application/json", + }, + }); + if (!res.ok) { + setAlbumSongs([]); + setAlbumTitle(albumName); + setAlbumArtist(routeArtist || "Unknown Artist"); + setErrorMessage("Failed to load SoundCloud playlist"); + } else { + const data = await res.json(); + const tracks: any[] = Array.isArray(data?.tracks) + ? data.tracks + : []; + const songs = tracks.map((t: any) => { + const artwork = t.artwork_url + ? t.artwork_url.replace("large.jpg", "t500x500.jpg") + : t.user?.avatar_url || ""; + return { + id: String(t.id || t.track_id || t.permalink || t.url || ""), + title: t.title || "Unknown Title", + artist: + t.user?.username || + t.artist || + routeArtist || + "Unknown Artist", + duration: t.duration ? Math.floor(t.duration / 1000) : 0, + thumbnail: artwork, + source: "soundcloud", + _isSoundCloud: true, + albumId: playlistUrlParam, + albumName: albumName, + }; + }); + setAlbumSongs(songs); + setAlbumTitle(data?.title || albumName); + setAlbumArtist( + routeArtist || + data?.artist || + data?.user?.username || + "Unknown Artist" + ); + const cover = + data?.artwork || + data?.image || + data?.thumbnail || + songs[0]?.thumbnail || + ""; + setAlbumArtUrl(cover || ""); + setErrorMessage(""); + } + } catch (err) { + setAlbumSongs([]); + setAlbumTitle(albumName); + setAlbumArtist(routeArtist || "Unknown Artist"); + setErrorMessage("Failed to load SoundCloud playlist"); + } } else if (source === "youtube" || source === "youtubemusic") { // Handle YouTube/YouTube Music playlists console.log( diff --git a/components/screens/SearchScreen.tsx b/components/screens/SearchScreen.tsx index 7945ef8..447a13c 100644 --- a/components/screens/SearchScreen.tsx +++ b/components/screens/SearchScreen.tsx @@ -13,7 +13,6 @@ import { TouchableWithoutFeedback, Platform, ScrollView, - FlatList, ActivityIndicator, Text, } from "react-native"; @@ -271,62 +270,45 @@ const SearchSection = memo( return null; } - const renderItem = useCallback( - ({ item }: { item: any }) => ( - onItemPress(item)} - > - - - ), - [onItemPress] - ); - return ( {title} - `${item.source || "yt"}-${item.id}`} - scrollEnabled={false} - showsVerticalScrollIndicator={false} - windowSize={5} - initialNumToRender={5} - maxToRenderPerBatch={5} - removeClippedSubviews={true} - getItemLayout={(data, index) => ({ - length: 80, - offset: 80 * index, - index, - })} - /> + {items.map((item) => ( + onItemPress(item)} + > + + + ))} ); } @@ -614,9 +596,14 @@ export default function SearchScreen({ navigation }: any) { 20 ); } else if (selectedSource === "jiosaavn") { - // JioSaavn Search - disabled - console.log("[\SearchScreen] JioSaavn search disabled"); - results = []; + // JioSaavn Search + console.log("[SearchScreen] Starting JioSaavn search"); + results = await searchAPI.searchWithJioSaavn( + queryToUse, + selectedFilter, + paginationRef.current.page, + 20 + ); } else if (selectedSource === "spotify") { // Placeholder for Spotify console.log(t("search.spotify_not_implemented")); @@ -1118,7 +1105,16 @@ export default function SearchScreen({ navigation }: any) { const handleAlbumPress = useCallback( async (item: any) => { // Navigate to album playlist screen - if (item.source === "jiosaavn") { + if (item.source === "soundcloud") { + navigation.navigate("AlbumPlaylist", { + albumId: item.id, + albumName: item.title, + albumArtist: item.author, + source: item.source, + href: item.href, + type: item.type, + }); + } else if (item.source === "jiosaavn") { navigation.navigate("AlbumPlaylist", { albumId: item.id, albumName: item.title, @@ -1471,7 +1467,7 @@ export default function SearchScreen({ navigation }: any) { { scrollPositionRef.current = event.nativeEvent.contentOffset.y; }} diff --git a/contexts/PlayerContext.tsx b/contexts/PlayerContext.tsx index f305a7e..15f3194 100644 --- a/contexts/PlayerContext.tsx +++ b/contexts/PlayerContext.tsx @@ -146,6 +146,19 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Audio monitoring listeners for cleanup const audioMonitoringListenersRef = useRef([]); + const updateIsPlayingFromState = useCallback(async () => { + try { + const playbackState = await TrackPlayer.getPlaybackState(); + const resolvedState = + (playbackState as any)?.state ?? (playbackState as any); + const nextIsPlaying = + resolvedState === State.Playing || + resolvedState === State.Buffering || + resolvedState === State.Connecting; + setIsPlaying(nextIsPlaying); + } catch (error) {} + }, []); + // Initialize TrackPlayer on startup useEffect(() => { const initializeTrackPlayer = async () => { @@ -171,6 +184,23 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ initializeTrackPlayer(); }, []); + useEffect(() => { + const subscription = TrackPlayer.addEventListener( + Event.PlaybackState, + (event: any) => { + const resolvedState = event?.state ?? event; + const nextIsPlaying = + resolvedState === State.Playing || + resolvedState === State.Buffering || + resolvedState === State.Connecting; + setIsPlaying(nextIsPlaying); + } + ); + return () => { + subscription?.remove?.(); + }; + }, []); + // Load liked songs from storage on startup useEffect(() => { const loadLikedSongs = async () => { @@ -205,7 +235,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Track Player handles background playback automatically } else if (nextAppState === "active") { console.log("[PlayerContext] App coming to foreground"); - // Keep the service running in case user switches tracks + updateIsPlayingFromState(); } } }; @@ -217,7 +247,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ return () => { subscription.remove(); }; - }, [isPlaying, currentTrack]); + }, [isPlaying, currentTrack, updateIsPlayingFromState]); // Load previously played songs from storage on startup useEffect(() => { @@ -288,77 +318,9 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ updateTheme(); }, [currentTrack?.thumbnail]); - // Monitor stream health and refresh if needed + // Monitor stream health and refresh if needed (legacy polling disabled in favor of TrackPlayer events) useEffect(() => { - console.log( - `[PlayerContext] Stream monitor check - isPlaying: ${isPlaying}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}` - ); - - if (!isPlaying || !currentTrack?.audioUrl) { - return; - } - - const streamMonitor = setInterval(async () => { - try { - const position = await trackPlayerService.getPosition(); - const duration = await trackPlayerService.getDuration(); - setPosition(position); - setDuration(duration); - console.log( - `[PlayerContext] Stream status - position: ${position}s, duration: ${duration}s` - ); - - if (position >= 0) { - // Check if position is advancing - const currentTime = Date.now(); - const currentPosition = position; - - // Store last known position and time - if (!streamCheckRef.current) { - streamCheckRef.current = { - position: currentPosition, - time: currentTime, - }; - return; - } - - const timeDiff = currentTime - streamCheckRef.current.time; - const positionDiff = - currentPosition - streamCheckRef.current.position; - - // If position hasn't changed in 5+ seconds, stream might be stuck - if (timeDiff > 5000 && positionDiff === 0) { - if (currentTrack) { - console.warn( - "[PlayerContext] Stream appears stuck, attempting refresh" - ); - handleStreamFailure(); - } else { - console.warn( - "[PlayerContext] Stream appears stuck but no current track, skipping refresh" - ); - } - streamCheckRef.current = null; - } else { - streamCheckRef.current = { position, time: currentTime }; - } - } else { - console.log( - `[PlayerContext] Stream not in valid state - position: ${position}` - ); - } - } catch (error) { - // Only log if it's not a "Player does not exist" error (which is expected during cleanup) - if (error?.toString().includes("Player does not exist")) { - clearInterval(streamMonitor); - } - } - }, 3000); // Check every 3 seconds - - return () => { - clearInterval(streamMonitor); - streamCheckRef.current = null; - }; + streamCheckRef.current = null; }, [isPlaying, currentTrack?.audioUrl]); const playTrack = useCallback( @@ -491,20 +453,21 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ audioUrl = originalStreamUrl; console.log(`[PlayerContext] Got SoundCloud URL: ${audioUrl}`); } else if (track._isJioSaavn || track.source === "jiosaavn") { - // JioSaavn disabled - skip fetching additional details console.log( - "[PlayerContext] JioSaavn song details disabled - using existing data" + `[PlayerContext] Getting JioSaavn streaming URL for track: ${track.id}` ); - audioUrl = track.audioUrl; - originalStreamUrl = audioUrl; - if (!audioUrl) { - console.log( - `[PlayerContext] JioSaavn track has no audio URL, playback failed for: ${track.title}` - ); - throw new Error( - `Unable to get audio stream for JioSaavn track: ${track.title}` - ); - } + originalStreamUrl = await getAudioStreamUrl( + track.id, + (status) => + console.log( + `[PlayerContext] JioSaavn streaming status: ${status}` + ), + "jiosaavn", + track.title, + track.artist + ); + audioUrl = originalStreamUrl; + console.log(`[PlayerContext] Got JioSaavn URL: ${audioUrl}`); } else { // Only fetch streaming URL if we don't already have one if (!track.audioUrl) { @@ -677,6 +640,9 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const position = event.position; const duration = event.duration; + setPosition(position); + setDuration(duration); + // Check if we've been in this position for too long (indicating silent playback) // Be more lenient for YouTube streams during initial buffering const timeSinceStart = Date.now() - initialBufferTime; @@ -721,24 +687,6 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // Could implement pre-emptive refresh here if needed } - // Check if position is stuck (indicates stream failure) - if (position === lastPosition) { - positionStuckCounter++; - - // Different thresholds for different stream types and phases - const threshold = - isYouTubeStream && isInitialBufferPhase ? 5 : STUCK_THRESHOLD; - - if (positionStuckCounter >= threshold && currentTrack) { - console.warn( - `[PlayerContext] Audio position stuck at ${position}s, possible stream failure (${isYouTubeStream ? "YouTube" : "SoundCloud"}, threshold: ${threshold})` - ); - // Try to reload the stream - handleStreamFailure(); - } - } else { - positionStuckCounter = 0; - } lastPosition = position; } ); @@ -1027,9 +975,9 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ ]); const seekTo = useCallback( - async (position: number) => { + async (positionSeconds: number) => { console.log( - `[PlayerContext] seekTo called - position: ${position}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}` + `[PlayerContext] seekTo called - positionSeconds: ${positionSeconds}, currentTrack?.audioUrl: ${!!currentTrack?.audioUrl}` ); if (!currentTrack?.audioUrl) { @@ -1038,6 +986,8 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ } try { + const positionMs = positionSeconds * 1000; + // Store current playing state to restore later const wasPlaying = isPlaying; @@ -1058,7 +1008,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ currentTrack.source === "jiosaavn") ) { console.log( - `[PlayerContext] Checking if position ${position}ms is cached for track: ${currentTrack.id}` + `[PlayerContext] Checking if position ${positionMs}ms is cached for track: ${currentTrack.id}` ); try { @@ -1068,7 +1018,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const positionCheck = await manager.isPositionCached( currentTrack.id, - position + positionMs ); console.log( `[PlayerContext] Position cache check: isCached=${positionCheck.isCached}, cacheEnd=${positionCheck.estimatedCacheEndMs}ms` @@ -1076,7 +1026,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ if (!positionCheck.isCached) { console.warn( - `[PlayerContext] Position ${position}ms is not cached (cache ends at ${positionCheck.estimatedCacheEndMs}ms). Attempting to cache more...` + `[PlayerContext] Position ${positionMs}ms is not cached (cache ends at ${positionCheck.estimatedCacheEndMs}ms). Attempting to cache more...` ); // Set loading state to indicate we're caching @@ -1086,7 +1036,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ // For YouTube tracks, use position-based caching if (currentTrack.source === "youtube") { console.log( - `[PlayerContext] Starting position-based caching from ${position}ms for YouTube track: ${currentTrack.id}` + `[PlayerContext] Starting position-based caching from ${positionMs}ms for YouTube track: ${currentTrack.id}` ); try { const { cacheYouTubeStreamFromPosition } = @@ -1099,7 +1049,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ const cachedUrl = await cacheYouTubeStreamFromPosition( currentTrack.audioUrl, currentTrack.id, - position / 1000, // Convert ms to seconds + positionSeconds, seekCacheController ); @@ -1163,7 +1113,7 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ ); } else { console.log( - `[PlayerContext] Position ${position}ms is within cached range` + `[PlayerContext] Position ${positionMs}ms is within cached range` ); } } catch (cacheCheckError) { @@ -1182,8 +1132,10 @@ export const PlayerProvider: React.FC<{ children: React.ReactNode }> = ({ return; } - console.log(`[PlayerContext] Seeking to position: ${position}`); - await trackPlayerService.seekTo(position); + console.log( + `[PlayerContext] Seeking to positionSeconds: ${positionSeconds}` + ); + await trackPlayerService.seekTo(positionSeconds); console.log("[PlayerContext] Seek completed successfully"); // Only resume playback if it was playing before the seek diff --git a/modules/audioStreaming.ts b/modules/audioStreaming.ts index 06fdaab..9de8b01 100644 --- a/modules/audioStreaming.ts +++ b/modules/audioStreaming.ts @@ -5,6 +5,8 @@ import { API, fetchWithRetry, DYNAMIC_INVIDIOUS_INSTANCES, + normalizeInvidiousInstance, + getJioSaavnSongEndpoint, } from "../components/core/api"; // Cache directory configuration @@ -80,8 +82,8 @@ export class AudioStreamManager { > = new Map(); // Maximum retry attempts for failed downloads - private readonly MAX_RETRY_ATTEMPTS = 3; - private readonly RETRY_DELAY = 2000; // 2 seconds + private readonly MAX_RETRY_ATTEMPTS = 1; + private readonly RETRY_DELAY = 500; // 2 seconds private readonly PROGRESS_UPDATE_INTERVAL = 1000; // 1 second private readonly MIN_PROGRESS_THRESHOLD = 0.5; // Minimum 0.5% progress per update @@ -522,17 +524,7 @@ export class AudioStreamManager { } private getCorsProxyUrl(url: string): string { - // Use a simple CORS proxy to bypass CORS issues - const corsProxies = [ - "https://corsproxy.io/?", - "https://api.allorigins.win/raw?url=", - "https://cors-anywhere.herokuapp.com/", - "https://proxy.cors.sh/", - ]; - - // Use the first available proxy - const proxy = corsProxies[0]; - return proxy + encodeURIComponent(url); + return url; } /** @@ -3427,207 +3419,71 @@ export class AudioStreamManager { return proxy; } - // Enhanced fetch with proxy rotation and retry logic private async fetchWithProxy( url: string, options: RequestInit = {}, retries = 3, timeout = 30000 ): Promise { + let lastError: unknown = null; + for (let i = 0; i <= retries; i++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - // Try direct request first (for YouTube/Invidious instances that work) - console.log(`[AudioStreamManager] Attempting direct request: ${url}`); - let response = await fetch(url, { + console.log( + `[AudioStreamManager] Attempting request (${i + 1}/${retries + 1}): ${url}` + ); + + const response = await fetch(url, { ...options, signal: controller.signal, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - Accept: "application/json, text/plain, * / *", + Accept: "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", ...options.headers, }, }); - // If direct request fails or is blocked, try with proxy - if ( - !response.ok || - response.status === 403 || - response.status === 429 - ) { - console.log( - `[AudioStreamManager] Direct request failed (${response.status}), trying proxy...` - ); - clearTimeout(timeoutId); - - const proxyUrl = this.getNextProxy(); - const proxiedUrl = proxyUrl + encodeURIComponent(url); - console.log(`[AudioStreamManager] Fetching via proxy: ${proxiedUrl}`); - - const proxyController = new AbortController(); - const proxyTimeoutId = setTimeout( - () => proxyController.abort(), - timeout - ); - - response = await fetch(proxiedUrl, { - ...options, - signal: proxyController.signal, - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - Accept: "application/json, text/plain, * / *", - "Accept-Language": "en-US,en;q=0.9", - ...options.headers, - }, - }); - clearTimeout(proxyTimeoutId); - } clearTimeout(timeoutId); - // Enhanced blocking detection - const contentType = response.headers.get("content-type"); - const responseText = await response.text(); - - // Check for various blocking indicators - const isHtmlResponse = contentType?.includes("text/html"); - const isApiRequest = url.includes("/api/"); - const hasCloudflare = - responseText.includes("cf-browser-verification") || - responseText.includes("cloudflare") || - responseText.includes("Checking your browser") || - responseText.includes("DDoS protection by Cloudflare"); - const hasBlockingPage = - responseText.includes("blocked") || - responseText.includes("access denied") || - responseText.includes("forbidden"); - - if ( - (isHtmlResponse && isApiRequest) || - hasCloudflare || - hasBlockingPage - ) { - throw new Error( - `Cloudflare/blocked API request: ${hasCloudflare ? "Cloudflare detected" : hasBlockingPage ? "Blocking page" : "HTML response to API request"}` - ); - } - - // Re-create response since we consumed the body - const recreatedResponse = new Response(responseText, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - if (response.ok) { - return recreatedResponse; - } - - // Handle specific HTTP error codes - if (response.status === 429) { - throw new Error("Rate limited (429): Too many requests"); - } else if (response.status === 503) { - throw new Error( - "Service unavailable (503): Instance may be overloaded" - ); - } else if (response.status === 502) { - throw new Error("Bad gateway (502): Instance proxy error"); - } else if (response.status === 404) { - throw new Error("Not found (404): Resource not available"); - } else if (response.status >= 500) { - throw new Error( - `Server error (${response.status}): Instance may be down` - ); + return response; } - if (i < retries) { - const proxyUrl = this.getNextProxy() + encodeURIComponent(url); - const proxyController = new AbortController(); - const proxyTimeoutId = setTimeout( - () => proxyController.abort(), - timeout - ); - - const proxyResponse = await fetch(proxyUrl, { - ...options, - signal: proxyController.signal, - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - Accept: "application/json, text/plain, *\/\/*", - "Accept-Language": "en-US,en;q=0.9", - ...options.headers, - }, - }); - clearTimeout(proxyTimeoutId); - - const proxyContentType = proxyResponse.headers.get("content-type"); - const proxyResponseText = await proxyResponse.text(); - - // Check for blocking indicators in proxy response - const proxyHasCloudflare = - proxyResponseText.includes("cf-browser-verification") || - proxyResponseText.includes("cloudflare") || - proxyResponseText.includes("Checking your browser") || - proxyResponseText.includes("DDoS protection by Cloudflare"); - - if ( - (proxyContentType?.includes("text/html") && - url.includes("/api/")) || - proxyHasCloudflare - ) { - throw new Error( - `Cloudflare/blocked API request via proxy: ${proxyHasCloudflare ? "Cloudflare detected" : "HTML response to API request"}` - ); - } - - // Re-create proxy response - const recreatedProxyResponse = new Response(proxyResponseText, { - status: proxyResponse.status, - statusText: proxyResponse.statusText, - headers: proxyResponse.headers, - }); - - if (proxyResponse.ok) { - return recreatedProxyResponse; - } - } + lastError = new Error( + `HTTP ${response.status}: ${response.statusText}` + ); } catch (error) { - if (i === retries) { - throw error; - } + lastError = error; + } - // Enhanced error logging - const errorMessage = - error instanceof Error ? error.message : String(error); + if (i < retries) { + const message = + lastError instanceof Error ? lastError.message : String(lastError); console.warn( - `[AudioStreamManager] fetchWithProxy attempt ${i + 1} failed for ${url}: ${errorMessage}` + `[AudioStreamManager] fetchWithProxy attempt ${i + 1} failed for ${url}: ${message}` ); - // Don't retry on certain errors (blocking, auth, etc.) - if ( - errorMessage.includes("Cloudflare") || - errorMessage.includes("blocked") || - errorMessage.includes("forbidden") || - errorMessage.includes("401") || - errorMessage.includes("403") - ) { - throw error; // Don't retry on blocking/auth errors - } - - // Exponential backoff with jitter const backoffMs = 2000 * Math.pow(2, i) + Math.random() * 1000; console.log( - `[AudioStreamManager] Waiting ${Math.round(backoffMs)}ms before retry ${i + 2}` + `[AudioStreamManager] Waiting ${Math.round( + backoffMs + )}ms before retry ${i + 2}` ); await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } - throw new Error(`Failed to fetch ${url} after ${retries + 1} attempts`); + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error( + lastError ? String(lastError) : `Failed to fetch ${url} after retries` + ); } private async tryLocalExtraction(videoId: string): Promise { @@ -3665,21 +3521,34 @@ export class AudioStreamManager { private async tryJioSaavn(videoId: string): Promise { try { - // Get video info with extended timeout + const directDetailsUrl = getJioSaavnSongEndpoint(videoId); + try { + const directData = await this.fetchWithProxy( + directDetailsUrl, + {}, + 2, + 15000 + ); + if (directData.ok) { + const json = await directData.json(); + const song = Array.isArray(json?.data) ? json.data[0] : null; + const downloads = song?.downloadUrl || []; + const q320 = downloads.find((d: any) => d.quality === "320kbps")?.url; + const q160 = downloads.find((d: any) => d.quality === "160kbps")?.url; + const q96 = downloads.find((d: any) => d.quality === "96kbps")?.url; + const anyUrl = q320 || q160 || q96 || downloads[0]?.url || song?.url; + if (anyUrl) { + return String(anyUrl).replace("http:", "https:"); + } + } + } catch {} const videoInfo = await this.getVideoInfoWithTimeout(videoId, 25000); - if (!videoInfo.title) { - throw new Error("Could not extract video title for JioSaavn search"); - } - - // Clean up title for better search results - const cleanTitle = videoInfo.title + const cleanTitle = (videoInfo.title || "") .replace(/\(.*?\)|\.|.*|\]/g, "") .trim(); const cleanAuthor = videoInfo.author ? videoInfo.author.replace(/ - Topic|VEVO|Official/gi, "").trim() : ""; - - // Try multiple JioSaavn endpoints const jiosaavnEndpoints = [ "https://jiosaavn-api-privatecvc.vercel.app/api/search/songs", "https://jiosaavn-api-v3.vercel.app/api/search/songs", @@ -4098,10 +3967,17 @@ export class AudioStreamManager { private async tryInvidious(videoId: string): Promise { // Use dynamic instances if available, otherwise fall back to hardcoded ones - const instances = + const baseInstances = DYNAMIC_INVIDIOUS_INSTANCES.length > 0 ? DYNAMIC_INVIDIOUS_INSTANCES - : ["https://echostreamz.com"]; + : API.invidious.length > 0 + ? API.invidious + : ["https://echostreamz.com"]; + const instances = [ + ...new Set( + baseInstances.map((instance) => normalizeInvidiousInstance(instance)) + ), + ]; console.log( `[AudioStreamManager] Invidious trying ${instances.length} instances for video: ${videoId}` @@ -4714,93 +4590,7 @@ export class AudioStreamManager { // Widget strategy failed } - // Strategy 3: Search for the specific track by title and artist - if (trackTitle || trackArtist) { - try { - const searchQuery = [trackTitle, trackArtist] - .filter(Boolean) - .join(" "); - - const searchUrl = `https://proxy.searchsoundcloud.com/tracks?q=${encodeURIComponent( - searchQuery - )}&limit=10&client_id=${this.SOUNDCLOUD_CLIENT_ID}`; - console.log(`[Audio] Search URL: ${searchUrl}`); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const searchResponse = await fetch(searchUrl, { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (searchResponse.ok) { - const searchData = await searchResponse.json(); - - if (searchData.collection && searchData.collection.length > 0) { - // Look for exact match by track ID first - const exactMatch = searchData.collection.find( - (track: any) => String(track.id) === trackId - ); - - if (exactMatch) { - return await this.extractSoundCloudStream( - exactMatch, - controller - ); - } - - // Look for title/artist match - const titleMatch = searchData.collection.find((track: any) => { - const trackTitleLower = track.title?.toLowerCase() || ""; - const searchTitleLower = trackTitle?.toLowerCase() || ""; - const trackArtistLower = - track.user?.username?.toLowerCase() || ""; - const searchArtistLower = trackArtist?.toLowerCase() || ""; - - return ( - (searchTitleLower && - trackTitleLower.includes(searchTitleLower)) || - (searchArtistLower && - trackArtistLower.includes(searchArtistLower)) - ); - }); - - if (titleMatch) { - return await this.extractSoundCloudStream( - titleMatch, - controller - ); - } - - // If no exact matches, try the first track with transcodings - const availableTrack = searchData.collection.find( - (track: any) => track.media?.transcodings?.length > 0 - ); - - if (availableTrack) { - return await this.extractSoundCloudStream( - availableTrack, - controller - ); - } - } - } - } catch (searchError) { - console.log( - `[Audio] Search strategy failed: ${ - searchError instanceof Error ? searchError.message : searchError - }` - ); - } - } - - // Strategy 4: Fallback - try to construct a direct stream URL + // Strategy 3: Fallback - try to construct a direct stream URL const fallbackUrl = `https://api.soundcloud.com/tracks/${trackId}/stream?client_id=${this.SOUNDCLOUD_CLIENT_ID}`; // Test if this URL works using CORS proxy diff --git a/modules/searchAPI.ts b/modules/searchAPI.ts index 610fd42..45fc57b 100644 --- a/modules/searchAPI.ts +++ b/modules/searchAPI.ts @@ -2,7 +2,6 @@ import { API, DYNAMIC_INVIDIOUS_INSTANCES, - updateInvidiousInstances, fetchStreamFromPipedWithFallback, fetchStreamFromInvidiousWithFallback, getJioSaavnSearchEndpoint, @@ -45,8 +44,7 @@ export const USER_AGENT = // Use centralized API configuration export const PIPED_INSTANCES = API.piped; -// Use dynamic Invidious instances from centralized config -let INVIDIOUS_INSTANCES = DYNAMIC_INVIDIOUS_INSTANCES; +// Use dynamic Invidious instances directly from centralized config /* ---------- HELPER FUNCTIONS ---------- */ const units = [ @@ -199,60 +197,13 @@ export const searchAPI = { if (!query.trim()) { return []; } - try { - console.log( - `[API] Fetching SoundCloud suggestions via proxy for: "${query}"` - ); - const tracks = await searchAPI.scrapeSoundCloudSearch(query); - if (!Array.isArray(tracks) || tracks.length === 0) { - const fallbackTerms = [ - "mix", - "remix", - "live", - "instrumental", - "extended", - ]; - return fallbackTerms.map((term) => `${query} ${term}`).slice(0, 5); - } - const titles = tracks - .map((t: any) => t && t.title) - .filter( - (t): t is string => typeof t === "string" && t.trim().length > 0 - ); - const uniqueTitles: string[] = []; - for (const title of titles) { - if (!uniqueTitles.includes(title)) { - uniqueTitles.push(title); - } - if (uniqueTitles.length >= 5) { - break; - } - } - if (uniqueTitles.length === 0) { - const fallbackTerms = [ - "mix", - "remix", - "live", - "instrumental", - "extended", - ]; - return fallbackTerms.map((term) => `${query} ${term}`).slice(0, 5); - } - console.log( - `[API] SoundCloud suggestion titles: ${uniqueTitles.length}`, - uniqueTitles - ); - return uniqueTitles.slice(0, 5); - } catch (e) { - const fallbackTerms = [ - "mix", - "remix", - "live", - "instrumental", - "extended", - ]; - return fallbackTerms.map((term) => `${query} ${term}`).slice(0, 5); - } + + const fallbackTerms = ["mix", "remix", "live", "instrumental", "extended"]; + + return [query, ...fallbackTerms.map((term) => `${query} ${term}`)].slice( + 0, + 5 + ); }, getSpotifySuggestions: async (query: string): Promise => { @@ -274,14 +225,12 @@ export const searchAPI = { } }, - // COMMENTED OUT: JioSaavn search disabled to focus on YouTube - /* // --- JIOSAAVN SEARCH --- searchWithJioSaavn: async ( query: string, filter?: string, page?: number, - limit?: number, + limit?: number ) => { console.log(`[API] Starting JioSaavn search for: "${query}"`); @@ -296,7 +245,7 @@ export const searchAPI = { }, }, 3, - 1000, + 1000 ); if (!data || !data.success || !data.data) { @@ -349,24 +298,24 @@ export const searchAPI = { }); console.log( - `[API] Filtered ${artists.length} artists to ${filteredArtists.length} relevant artists`, + `[API] Filtered ${artists.length} artists to ${filteredArtists.length} relevant artists` ); // Log exact matches for debugging const exactMatches = filteredArtists.filter( (artist: any) => (artist.title || "").toLowerCase().trim() === - query.toLowerCase().trim(), + query.toLowerCase().trim() ); if (exactMatches.length > 0) { console.log( `[API] Found ${exactMatches.length} exact artist matches for "${query}":`, - exactMatches.map((a: any) => a.title), + exactMatches.map((a: any) => a.title) ); } console.log( - `[API] 🟢 JioSaavn Success: Found ${songs.length} songs, ${albums.length} albums, ${artists.length} artists, ${topQuery.length} top queries`, + `[API] 🟢 JioSaavn Success: Found ${songs.length} songs, ${albums.length} albums, ${artists.length} artists, ${topQuery.length} top queries` ); // Format all results to match SearchResult interface @@ -500,8 +449,7 @@ export const searchAPI = { // Check for exact artist matches in the filtered artists const exactArtistMatches = artistsResults.filter( - (item) => - item.title.toLowerCase().trim() === query.toLowerCase().trim(), + (item) => item.title.toLowerCase().trim() === query.toLowerCase().trim() ); // Build final result array in the correct order: Top Results (with artist first if exact match), Songs, Albums @@ -529,8 +477,7 @@ export const searchAPI = { // Add remaining artists (non-exact matches) const remainingArtists = artistsResults.filter( - (item) => - item.title.toLowerCase().trim() !== query.toLowerCase().trim(), + (item) => item.title.toLowerCase().trim() !== query.toLowerCase().trim() ); if (remainingArtists.length > 0) { finalResults = [...finalResults, ...remainingArtists]; @@ -543,7 +490,6 @@ export const searchAPI = { return []; } }, - */ // COMMENTED OUT: JioSaavn song details disabled to focus on YouTube /* @@ -1025,7 +971,11 @@ export const searchAPI = { const endpoint = `/search?q=${encodeURIComponent( query )}&sort_by=${sortParam}${pageParam}`; - const data = await fetchWithFallbacks(INVIDIOUS_INSTANCES, endpoint); + const invidiousInstances = + DYNAMIC_INVIDIOUS_INSTANCES.length > 0 + ? DYNAMIC_INVIDIOUS_INSTANCES + : [...API.invidious]; + const data = await fetchWithFallbacks(invidiousInstances, endpoint); return Array.isArray(data) ? data : []; }, @@ -1036,68 +986,81 @@ export const searchAPI = { page?: number, limit?: number ) => { - // Enhanced multilingual support for SoundCloud - const isMultilingual = /[^\u0000-\u007F]/.test(query); - - // Use SoundCloud proxy API try { - const tracks = await searchAPI.scrapeSoundCloudSearch(query); + const f = (filter || "").toLowerCase(); + if (f === "playlists" || f === "albums") { + const type = f === "playlists" ? "playlists" : "albums"; + const collections = await searchAPI.scrapeSoundCloudCollections( + query, + type, + page, + limit + ); + if (!Array.isArray(collections)) { + return []; + } + return collections.map((c: any) => { + const thumb = + c.thumbnail || + c.artwork || + c.artworkUrl || + c.image || + c.thumbnailUrl || + c.img || + ""; + return { + id: c.url || c.id || "", + title: c.title || c.name || "Unknown", + author: c.artist || c.author || c.uploader || c.user || "Unknown", + duration: undefined, + views: undefined, + videoCount: c.trackCount || c.tracks || undefined, + uploaded: undefined, + thumbnailUrl: thumb, + img: thumb, + href: c.url || "", + source: "soundcloud", + type: type === "playlists" ? "playlist" : "album", + }; + }); + } - // Format the results manually instead of calling formatSearchResults + const tracks = await searchAPI.scrapeSoundCloudSearch(query, page, limit); if (!Array.isArray(tracks)) { return []; } - - // Deduplicate tracks by ID to prevent duplicate keys const seenIds = new Set(); - return ( - tracks - .filter((track) => track && track._isSoundCloud) - .filter((track) => { - const trackId = String(track.id); - if (seenIds.has(trackId)) { - console.log( - `[API] Skipping duplicate SoundCloud track: ${trackId}` - ); - return false; - } - seenIds.add(trackId); - return true; - }) - // Filter out tracks that are likely to be unavailable - .filter((track) => { - // Skip tracks with very short duration (likely incomplete) - if (track.duration && track.duration < 10000) { - console.log(`[API] Skipping short track: ${track.id}`); - return false; - } - // Skip tracks with no duration info - if (!track.duration) { - console.log(`[API] Skipping track with no duration: ${track.id}`); - return false; - } - return true; - }) - .map((track) => { - const artwork = track.artwork_url - ? track.artwork_url.replace("large.jpg", "t500x500.jpg") - : track.user?.avatar_url; - return { - id: String(track.id), - title: track.title || "Unknown Title", - author: track.user?.username || "Unknown Artist", - duration: track.duration - ? String(Math.floor(track.duration / 1000)) - : "0", - views: String(track.playback_count || 0), - uploaded: fmtTimeAgo(new Date(track.created_at).getTime()), - thumbnailUrl: artwork, - img: artwork, - href: track.permalink_url, - source: "soundcloud", - }; - }) - ); + return tracks + .filter((track) => track && track._isSoundCloud) + .filter((track) => { + const trackId = String(track.id); + if (seenIds.has(trackId)) { + return false; + } + seenIds.add(trackId); + return true; + }) + .filter((track) => track.duration && track.duration >= 10000) + .map((track) => { + const artwork = track.artwork_url + ? track.artwork_url.replace("large.jpg", "t500x500.jpg") + : track.user?.avatar_url; + return { + id: String(track.id), + title: track.title || "Unknown Title", + author: track.user?.username || "Unknown Artist", + duration: track.duration + ? String(Math.floor(track.duration / 1000)) + : "0", + views: String(track.playback_count || 0), + uploaded: fmtTimeAgo(new Date(track.created_at).getTime()), + thumbnailUrl: artwork, + img: artwork, + href: track.permalink_url, + source: "soundcloud", + type: "song", + }; + }); } catch (error) { return []; } @@ -1315,53 +1278,105 @@ export const searchAPI = { }); }, - // --- SOUNDCLOUD PROXY API --- - scrapeSoundCloudSearch: async (query: string) => { + scrapeSoundCloudSearch: async ( + query: string, + page?: number, + limit?: number + ) => { try { - // Use the SoundCloud proxy API - const searchUrl = `https://proxy.searchsoundcloud.com/tracks?q=${encodeURIComponent( + const CLIENT_ID = "gqKBMSuBw5rbN9rDRYPqKNvF17ovlObu"; + const pageSize = limit && limit > 0 ? limit : 20; + const offset = page && page > 1 ? (page - 1) * pageSize : 0; + const url = `https://api-v2.soundcloud.com/search/tracks?q=${encodeURIComponent( query - )}`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(searchUrl, { - signal: controller.signal, + )}&client_id=${CLIENT_ID}&limit=${pageSize}&offset=${offset}`; + const res = await fetch(url, { headers: { - "User-Agent": USER_AGENT, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", Accept: "application/json", }, }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + if (!res.ok) { + return []; } + const data = await res.json(); + const collection = Array.isArray(data?.collection) ? data.collection : []; + return collection + .filter((item: any) => item && item.kind === "track") + .map((item: any) => ({ + _isSoundCloud: true, + kind: "track", + id: item.id, + title: item.title, + duration: item.duration, + playback_count: item.playback_count, + created_at: item.created_at, + permalink_url: item.permalink_url, + artwork_url: item.artwork_url, + user: { + username: item.user?.username, + avatar_url: item.user?.avatar_url, + }, + })); + } catch { + return []; + } + }, - const data = await response.json(); - - if (!data || !data.collection || !Array.isArray(data.collection)) { - throw new Error("Invalid response format"); + scrapeSoundCloudCollections: async ( + query: string, + type: "playlists" | "albums", + page?: number, + limit?: number + ) => { + try { + const pageSize = limit && limit > 0 ? limit : 20; + const url = `https://beatseek.io/api/search?query=${encodeURIComponent( + query + )}&platform=soundcloud&type=${type}&sort=both&limit=${pageSize}`; + const res = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + Accept: "application/json", + }, + }); + if (!res.ok) { + return []; } - - // Convert proxy API response to SoundCloud format - const tracks = data.collection.map((track: any) => ({ - id: String(track.id), - title: track.title, - user: { username: track.user?.username || "Unknown Artist" }, - duration: track.duration || 0, - playback_count: track.playback_count || 0, - created_at: track.created_at || new Date().toISOString(), - permalink_url: - track.permalink_url || `https://soundcloud.com/tracks/${track.id}`, - artwork_url: track.artwork_url || null, - _isSoundCloud: true, + const data = await res.json(); + const items = Array.isArray(data?.results) + ? data.results + : Array.isArray(data) + ? data + : []; + return items.map((item: any) => ({ + url: item.url || item.permalink || "", + title: item.title || item.name || "", + artist: item.artist || "", + author: + item.author || + item.artist || + item.uploader || + item.user?.username || + item.creator || + "", + artwork: + item.artwork || + item.artworkUrl || + item.thumbnail || + item.image || + item.cover || + item.img || + "", + trackCount: + item.trackCount || + item.tracksCount || + item.tracks?.length || + undefined, })); - - return tracks; - } catch (e: any) { + } catch { return []; } },