diff --git a/resources/[tools]/nordi_dj/client/main.lua b/resources/[tools]/nordi_dj/client/main.lua
new file mode 100644
index 000000000..c2c60231a
--- /dev/null
+++ b/resources/[tools]/nordi_dj/client/main.lua
@@ -0,0 +1,587 @@
+local QBCore = exports['qb-core']:GetCoreObject()
+local PlayerData = {}
+local currentDJBooth = nil
+local isPlaying = false
+local currentVolume = Config.DefaultVolume
+local currentSong = nil
+local playlists = {}
+local currentPlaylist = nil
+local currentSongIndex = 1
+
+-- Events
+RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function()
+ PlayerData = QBCore.Functions.GetPlayerData()
+end)
+
+RegisterNetEvent('QBCore:Client:OnJobUpdate', function(JobInfo)
+ PlayerData.job = JobInfo
+end)
+
+-- Key Mapping
+RegisterKeyMapping('opendj', 'Open DJ Menu', 'keyboard', Config.OpenMenuKey)
+
+RegisterCommand('opendj', function()
+ if not CanUseDJScript() then
+ lib.notify({
+ title = 'DJ System',
+ description = 'Du hast keine Berechtigung das DJ System zu nutzen!',
+ type = 'error'
+ })
+ return
+ end
+
+ local nearbyBooth = GetNearbyDJBooth()
+ if nearbyBooth then
+ currentDJBooth = nearbyBooth
+ OpenDJMenu()
+ else
+ lib.notify({
+ title = 'DJ System',
+ description = 'Du bist nicht in der Nähe einer DJ Booth!',
+ type = 'error'
+ })
+ end
+end)
+
+-- Functions
+function CanUseDJScript()
+ if not Config.UseJobRestriction then
+ return true
+ end
+
+ if not PlayerData.job then
+ return false
+ end
+
+ for _, job in pairs(Config.AllowedJobs) do
+ if PlayerData.job.name == job then
+ return true
+ end
+ end
+
+ return false
+end
+
+function GetNearbyDJBooth()
+ local playerCoords = GetEntityCoords(PlayerPedId())
+
+ for i, booth in pairs(Config.DJBooths) do
+ local distance = #(playerCoords - booth.coords)
+ if distance <= 3.0 then
+ return booth
+ end
+ end
+
+ return nil
+end
+
+function OpenDJMenu()
+ TriggerServerEvent('dj:server:getPlaylists')
+
+ local options = {
+ {
+ title = 'YouTube Song abspielen',
+ description = 'Spiele einen Song von YouTube ab',
+ icon = 'fab fa-youtube',
+ onSelect = function()
+ OpenYouTubeMenu()
+ end
+ },
+ {
+ title = 'Direkte URL abspielen',
+ description = 'Spiele einen Song von einer direkten URL ab',
+ icon = 'play',
+ onSelect = function()
+ OpenDirectUrlMenu()
+ end
+ },
+ {
+ title = 'Musik stoppen',
+ description = 'Stoppe die aktuelle Musik',
+ icon = 'stop',
+ onSelect = function()
+ StopMusic()
+ end
+ },
+ {
+ title = 'Lautstärke ändern',
+ description = 'Aktuelle Lautstärke: ' .. currentVolume .. '%',
+ icon = 'volume-up',
+ onSelect = function()
+ OpenVolumeMenu()
+ end
+ },
+ {
+ title = 'Playlists verwalten',
+ description = 'Erstelle und verwalte Playlists',
+ icon = 'list',
+ onSelect = function()
+ OpenPlaylistMenu()
+ end
+ }
+ }
+
+ if isPlaying and currentSong then
+ table.insert(options, 2, {
+ title = 'Aktueller Song',
+ description = currentSong.title,
+ icon = 'music',
+ disabled = true
+ })
+ end
+
+ lib.registerContext({
+ id = 'dj_main_menu',
+ title = 'DJ System - ' .. currentDJBooth.name,
+ options = options
+ })
+
+ lib.showContext('dj_main_menu')
+end
+
+function OpenYouTubeMenu()
+ local input = lib.inputDialog('YouTube Song abspielen', {
+ {type = 'input', label = 'Song Titel', placeholder = 'z.B. Daft Punk - One More Time'},
+ {type = 'input', label = 'YouTube URL', placeholder = 'https://www.youtube.com/watch?v=...'}
+ })
+
+ if input and input[1] and input[2] then
+ if not IsValidYouTubeUrl(input[2]) then
+ lib.notify({
+ title = 'DJ System',
+ description = 'Ungültige YouTube URL!',
+ type = 'error'
+ })
+ return
+ end
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'YouTube URL wird konvertiert, bitte warten...',
+ type = 'info'
+ })
+
+ PlayMusic(input[1], input[2])
+ end
+end
+
+function OpenDirectUrlMenu()
+ local input = lib.inputDialog('Direkte URL abspielen', {
+ {type = 'input', label = 'Song Titel', placeholder = 'Titel des Songs'},
+ {type = 'input', label = 'Direkte MP3/Audio URL', placeholder = 'https://example.com/song.mp3'}
+ })
+
+ if input and input[1] and input[2] then
+ PlayMusic(input[1], input[2])
+ end
+end
+
+function IsValidYouTubeUrl(url)
+ local patterns = {
+ "youtube%.com/watch%?v=",
+ "youtu%.be/",
+ "youtube%.com/embed/"
+ }
+
+ for _, pattern in ipairs(patterns) do
+ if string.match(url, pattern) then
+ return true
+ end
+ end
+
+ return false
+end
+
+function OpenVolumeMenu()
+ local input = lib.inputDialog('Lautstärke einstellen', {
+ {
+ type = 'slider',
+ label = 'Lautstärke (%)',
+ description = 'Höhere Lautstärke = größere Reichweite',
+ default = currentVolume,
+ min = 0,
+ max = Config.MaxVolume,
+ step = 5
+ }
+ })
+
+ if input and input[1] then
+ SetVolume(input[1])
+ end
+end
+
+function OpenPlaylistMenu()
+ local options = {
+ {
+ title = 'Neue Playlist erstellen',
+ description = 'Erstelle eine neue Playlist',
+ icon = 'plus',
+ onSelect = function()
+ CreateNewPlaylist()
+ end
+ }
+ }
+
+ for _, playlist in pairs(playlists) do
+ table.insert(options, {
+ title = playlist.name,
+ description = #playlist.songs .. ' Songs',
+ icon = 'music',
+ onSelect = function()
+ OpenPlaylistOptions(playlist)
+ end
+ })
+ end
+
+ lib.registerContext({
+ id = 'playlist_menu',
+ title = 'Playlist Verwaltung',
+ menu = 'dj_main_menu',
+ options = options
+ })
+
+ lib.showContext('playlist_menu')
+end
+
+function CreateNewPlaylist()
+ local input = lib.inputDialog('Neue Playlist', {
+ {type = 'input', label = 'Playlist Name', placeholder = 'Meine YouTube Playlist'}
+ })
+
+ if input and input[1] then
+ TriggerServerEvent('dj:server:createPlaylist', input[1])
+ end
+end
+
+function OpenPlaylistOptions(playlist)
+ local options = {
+ {
+ title = 'Playlist abspielen',
+ description = 'Spiele alle Songs der Playlist ab',
+ icon = 'play',
+ onSelect = function()
+ PlayPlaylist(playlist)
+ end
+ },
+ {
+ title = 'YouTube Song hinzufügen',
+ description = 'Füge einen YouTube Song zur Playlist hinzu',
+ icon = 'fab fa-youtube',
+ onSelect = function()
+ AddYouTubeSongToPlaylist(playlist)
+ end
+ },
+ {
+ title = 'Direkten Song hinzufügen',
+ description = 'Füge einen Song per direkter URL hinzu',
+ icon = 'plus',
+ onSelect = function()
+ AddDirectSongToPlaylist(playlist)
+ end
+ },
+ {
+ title = 'Songs anzeigen',
+ description = 'Zeige alle Songs in der Playlist',
+ icon = 'list',
+ onSelect = function()
+ ShowPlaylistSongs(playlist)
+ end
+ },
+ {
+ title = 'Playlist löschen',
+ description = 'Lösche diese Playlist',
+ icon = 'trash',
+ onSelect = function()
+ DeletePlaylist(playlist)
+ end
+ }
+ }
+
+ lib.registerContext({
+ id = 'playlist_options',
+ title = playlist.name,
+ menu = 'playlist_menu',
+ options = options
+ })
+
+ lib.showContext('playlist_options')
+end
+
+function AddYouTubeSongToPlaylist(playlist)
+ local input = lib.inputDialog('YouTube Song hinzufügen', {
+ {type = 'input', label = 'Song Titel', placeholder = 'z.B. Avicii - Levels'},
+ {type = 'input', label = 'YouTube URL', placeholder = 'https://www.youtube.com/watch?v=...'}
+ })
+
+ if input and input[1] and input[2] then
+ if not IsValidYouTubeUrl(input[2]) then
+ lib.notify({
+ title = 'DJ System',
+ description = 'Ungültige YouTube URL!',
+ type = 'error'
+ })
+ return
+ end
+
+ TriggerServerEvent('dj:server:addSongToPlaylist', playlist.id, {
+ title = input[1],
+ url = input[2]
+ })
+ end
+end
+
+function AddDirectSongToPlaylist(playlist)
+ local input = lib.inputDialog('Direkten Song hinzufügen', {
+ {type = 'input', label = 'Song Titel', placeholder = 'Titel des Songs'},
+ {type = 'input', label = 'Direkte URL', placeholder = 'https://example.com/song.mp3'}
+ })
+
+ if input and input[1] and input[2] then
+ TriggerServerEvent('dj:server:addSongToPlaylist', playlist.id, {
+ title = input[1],
+ url = input[2]
+ })
+ end
+end
+
+function PlayMusic(title, url)
+ if not currentDJBooth then return end
+
+ currentSong = {title = title, url = url}
+ isPlaying = true
+
+ TriggerServerEvent('dj:server:playMusic', {
+ title = title,
+ url = url,
+ volume = currentVolume,
+ booth = currentDJBooth,
+ range = CalculateRange()
+ })
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'Lade: ' .. title,
+ type = 'info'
+ })
+end
+
+function StopMusic()
+ if not isPlaying then
+ lib.notify({
+ title = 'DJ System',
+ description = 'Es läuft gerade keine Musik!',
+ type = 'error'
+ })
+ return
+ end
+
+ isPlaying = false
+ currentSong = nil
+ currentPlaylist = nil
+
+ TriggerServerEvent('dj:server:stopMusic', currentDJBooth)
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'Musik gestoppt',
+ type = 'success'
+ })
+end
+
+function SetVolume(volume)
+ currentVolume = volume
+
+ if isPlaying then
+ TriggerServerEvent('dj:server:setVolume', {
+ volume = volume,
+ booth = currentDJBooth,
+ range = CalculateRange()
+ })
+ end
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'Lautstärke auf ' .. volume .. '% gesetzt',
+ type = 'success'
+ })
+end
+
+function CalculateRange()
+ local baseRange = currentDJBooth.range
+ local maxRange = currentDJBooth.maxRange
+ local volumePercent = currentVolume / 100
+
+ return baseRange + ((maxRange - baseRange) * volumePercent)
+end
+
+function PlayPlaylist(playlist)
+ if #playlist.songs == 0 then
+ lib.notify({
+ title = 'DJ System',
+ description = 'Playlist ist leer!',
+ type = 'error'
+ })
+ return
+ end
+
+ currentPlaylist = playlist
+ currentSongIndex = 1
+
+ local firstSong = playlist.songs[1]
+ PlayMusic(firstSong.title, firstSong.url)
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'Playlist "' .. playlist.name .. '" wird abgespielt',
+ type = 'success'
+ })
+end
+
+function ShowPlaylistSongs(playlist)
+ local options = {}
+
+ for i, song in pairs(playlist.songs) do
+ local songType = IsValidYouTubeUrl(song.url) and "YouTube" or "Direkt"
+ table.insert(options, {
+ title = song.title,
+ description = 'Typ: ' .. songType .. ' | Klicken zum Abspielen',
+ icon = IsValidYouTubeUrl(song.url) and 'fab fa-youtube' or 'music',
+ onSelect = function()
+ PlayMusic(song.title, song.url)
+ end
+ })
+ end
+
+ if #options == 0 then
+ table.insert(options, {
+ title = 'Keine Songs',
+ description = 'Diese Playlist ist leer',
+ icon = 'exclamation'
+ })
+ end
+
+ lib.registerContext({
+ id = 'playlist_songs',
+ title = playlist.name .. ' - Songs',
+ menu = 'playlist_options',
+ options = options
+ })
+
+ lib.showContext('playlist_songs')
+end
+
+function DeletePlaylist(playlist)
+ local confirm = lib.alertDialog({
+ header = 'Playlist löschen',
+ content = 'Möchtest du die Playlist "' .. playlist.name .. '" wirklich löschen?',
+ centered = true,
+ cancel = true
+ })
+
+ if confirm == 'confirm' then
+ TriggerServerEvent('dj:server:deletePlaylist', playlist.id)
+ end
+end
+
+-- Server Events
+RegisterNetEvent('dj:client:playMusic', function(data)
+ SendNUIMessage({
+ type = 'playMusic',
+ url = data.url,
+ volume = data.volume,
+ title = data.title
+ })
+
+ lib.notify({
+ title = 'DJ System',
+ description = 'Spielt ab: ' .. data.title,
+ type = 'success'
+ })
+end)
+
+RegisterNetEvent('dj:client:stopMusic', function()
+ SendNUIMessage({
+ type = 'stopMusic'
+ })
+end)
+
+RegisterNetEvent('dj:client:setVolume', function(volume)
+ SendNUIMessage({
+ type = 'setVolume',
+ volume = volume
+ })
+end)
+
+RegisterNetEvent('dj:client:updatePlaylists', function(data)
+ playlists = data
+end)
+
+RegisterNetEvent('dj:client:notify', function(message, type)
+ lib.notify({
+ title = 'DJ System',
+ description = message,
+ type = type or 'info'
+ })
+end)
+
+-- Music Range Check
+CreateThread(function()
+ while true do
+ Wait(1000)
+
+ if isPlaying and currentDJBooth then
+ local playerCoords = GetEntityCoords(PlayerPedId())
+ local distance = #(playerCoords - currentDJBooth.coords)
+ local range = CalculateRange()
+
+ if distance > range then
+ SendNUIMessage({
+ type = 'fadeOut'
+ })
+ else
+ SendNUIMessage({
+ type = 'fadeIn'
+ })
+ end
+ end
+ end
+end)
+
+-- Song End Handler
+RegisterNUICallback('songEnded', function(data, cb)
+ if currentPlaylist and currentSongIndex < #currentPlaylist.songs then
+ -- Nächster Song in Playlist
+ currentSongIndex = currentSongIndex + 1
+ local nextSong = currentPlaylist.songs[currentSongIndex]
+ PlayMusic(nextSong.title, nextSong.url)
+ else
+ -- Playlist beendet
+ isPlaying = false
+ currentSong = nil
+ currentPlaylist = nil
+ currentSongIndex = 1
+ end
+ cb('ok')
+end)
+
+-- Audio Error Handler
+RegisterNUICallback('audioError', function(data, cb)
+ lib.notify({
+ title = 'DJ System',
+ description = 'Audio Fehler: ' .. (data.error or 'Unbekannter Fehler'),
+ type = 'error'
+ })
+
+ -- Reset playing state
+ isPlaying = false
+ currentSong = nil
+
+ cb('ok')
+end)
+
+-- Song Progress (optional)
+RegisterNUICallback('songProgress', function(data, cb)
+ -- Du kannst hier Song-Progress verarbeiten
+ -- z.B. für eine Progress-Bar im Menü
+ cb('ok')
+end)
\ No newline at end of file
diff --git a/resources/[tools]/nordi_dj/config.lua b/resources/[tools]/nordi_dj/config.lua
new file mode 100644
index 000000000..5f940d501
--- /dev/null
+++ b/resources/[tools]/nordi_dj/config.lua
@@ -0,0 +1,72 @@
+Config = {}
+
+-- Allgemeine Einstellungen
+Config.UseJobRestriction = true
+Config.AllowedJobs = {
+ 'dj',
+ 'nightclub',
+ 'admin'
+}
+
+Config.OpenMenuKey = 'F7'
+Config.MaxVolume = 100
+Config.DefaultVolume = 50
+
+-- YouTube API Einstellungen
+Config.YouTubeAPI = {
+ enabled = true,
+ -- Du kannst eine kostenlose API von https://rapidapi.com/ytjar/api/youtube-mp36 bekommen
+ -- Oder eine andere YouTube to MP3 API verwenden
+ apiUrl = "https://youtube-mp36.p.rapidapi.com/dl",
+ apiKey = "DEIN_API_KEY_HIER", -- Ersetze mit deinem API Key
+ headers = {
+ ["X-RapidAPI-Key"] = "DEIN_API_KEY_HIER",
+ ["X-RapidAPI-Host"] = "youtube-mp36.p.rapidapi.com"
+ }
+}
+
+-- Alternative: Lokaler YouTube-DL Server (empfohlen für bessere Performance)
+Config.LocalYouTubeConverter = {
+ enabled = false, -- Setze auf true wenn du einen lokalen Converter verwendest
+ serverUrl = "http://localhost:3000/convert" -- URL zu deinem lokalen Converter
+}
+
+-- DJ Booth Locations
+Config.DJBooths = {
+ {
+ name = "Vanilla Unicorn",
+ coords = vector3(120.13, -1281.72, 29.48),
+ range = 50.0,
+ maxRange = 100.0
+ },
+ {
+ name = "Bahama Mamas",
+ coords = vector3(-1387.08, -618.52, 30.82),
+ range = 50.0,
+ maxRange = 100.0
+ },
+ {
+ name = "Diamond Casino",
+ coords = vector3(1549.78, 252.44, -46.01),
+ range = 60.0,
+ maxRange = 120.0
+ }
+}
+
+-- Standard Playlists mit YouTube Links
+Config.DefaultPlaylists = {
+ {
+ name = "Party Hits",
+ songs = {
+ {title = "Daft Punk - One More Time", url = "https://www.youtube.com/watch?v=FGBhQbmPwH8"},
+ {title = "Calvin Harris - Feel So Close", url = "https://www.youtube.com/watch?v=dGghkjpNCQ8"}
+ }
+ },
+ {
+ name = "Chill Music",
+ songs = {
+ {title = "Kygo - Firestone", url = "https://www.youtube.com/watch?v=9Sc-ir2UwGU"},
+ {title = "Avicii - Levels", url = "https://www.youtube.com/watch?v=_ovdm2yX4MA"}
+ }
+ }
+}
diff --git a/resources/[tools]/nordi_dj/fxmanifest.lua b/resources/[tools]/nordi_dj/fxmanifest.lua
new file mode 100644
index 000000000..865fe6b53
--- /dev/null
+++ b/resources/[tools]/nordi_dj/fxmanifest.lua
@@ -0,0 +1,34 @@
+fx_version 'cerulean'
+game 'gta5'
+
+author 'YourName'
+description 'QBCore DJ Script with YouTube Support'
+version '1.0.0'
+
+shared_scripts {
+ '@ox_lib/init.lua',
+ 'config.lua'
+}
+
+client_scripts {
+ 'client/main.lua'
+}
+
+server_scripts {
+ '@oxmysql/lib/MySQL.lua',
+ 'server/main.lua'
+}
+
+ui_page 'html/index.html'
+
+files {
+ 'html/index.html',
+ 'html/style.css',
+ 'html/script.js'
+}
+
+dependencies {
+ 'qb-core',
+ 'ox_lib',
+ 'oxmysql'
+}
diff --git a/resources/[tools]/nordi_dj/html/index.html b/resources/[tools]/nordi_dj/html/index.html
new file mode 100644
index 000000000..f7aef6b10
--- /dev/null
+++ b/resources/[tools]/nordi_dj/html/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ DJ System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/[tools]/nordi_dj/html/script.js b/resources/[tools]/nordi_dj/html/script.js
new file mode 100644
index 000000000..dd7ca2526
--- /dev/null
+++ b/resources/[tools]/nordi_dj/html/script.js
@@ -0,0 +1,401 @@
+let audioPlayer = null;
+let currentVolume = 50;
+let isPlaying = false;
+let currentSong = null;
+let fadeInterval = null;
+
+// Initialize when page loads
+document.addEventListener('DOMContentLoaded', function() {
+ audioPlayer = document.getElementById('audio-player');
+ setupAudioPlayer();
+});
+
+// Setup audio player with event listeners
+function setupAudioPlayer() {
+ if (!audioPlayer) return;
+
+ // Audio Events
+ audioPlayer.addEventListener('loadstart', function() {
+ console.log('DJ System: Loading started');
+ });
+
+ audioPlayer.addEventListener('canplay', function() {
+ console.log('DJ System: Can start playing');
+ });
+
+ audioPlayer.addEventListener('play', function() {
+ console.log('DJ System: Playback started');
+ isPlaying = true;
+ });
+
+ audioPlayer.addEventListener('pause', function() {
+ console.log('DJ System: Playback paused');
+ isPlaying = false;
+ });
+
+ audioPlayer.addEventListener('ended', function() {
+ console.log('DJ System: Song ended');
+ isPlaying = false;
+ // Notify FiveM that song ended (for playlist functionality)
+ fetch(`https://${GetParentResourceName()}/songEnded`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify({})
+ });
+ });
+
+ audioPlayer.addEventListener('error', function(e) {
+ console.error('DJ System: Audio error', e);
+ isPlaying = false;
+ // Notify FiveM about the error
+ fetch(`https://${GetParentResourceName()}/audioError`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify({
+ error: 'Audio playback error',
+ code: audioPlayer.error ? audioPlayer.error.code : 'unknown'
+ })
+ });
+ });
+
+ audioPlayer.addEventListener('loadedmetadata', function() {
+ console.log('DJ System: Metadata loaded, duration:', audioPlayer.duration);
+ });
+
+ audioPlayer.addEventListener('timeupdate', function() {
+ // Optional: Send progress updates
+ if (isPlaying && audioPlayer.duration) {
+ const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
+ // You can use this for progress bars if needed
+ }
+ });
+}
+
+// Main message handler from FiveM
+window.addEventListener('message', function(event) {
+ const data = event.data;
+
+ switch(data.type) {
+ case 'playMusic':
+ playMusic(data.url, data.volume, data.title);
+ break;
+ case 'stopMusic':
+ stopMusic();
+ break;
+ case 'setVolume':
+ setVolume(data.volume);
+ break;
+ case 'fadeOut':
+ fadeOut();
+ break;
+ case 'fadeIn':
+ fadeIn();
+ break;
+ case 'pauseMusic':
+ pauseMusic();
+ break;
+ case 'resumeMusic':
+ resumeMusic();
+ break;
+ }
+});
+
+// Play music function with YouTube support
+async function playMusic(url, volume, title = 'Unknown') {
+ if (!audioPlayer) {
+ console.error('DJ System: Audio player not initialized');
+ return;
+ }
+
+ try {
+ // Stop current music first
+ stopMusic();
+
+ console.log('DJ System: Attempting to play:', title, url);
+
+ // Set volume
+ currentVolume = volume || 50;
+ audioPlayer.volume = currentVolume / 100;
+
+ // Handle different URL types
+ let playableUrl = await processUrl(url);
+
+ if (!playableUrl) {
+ console.error('DJ System: Could not process URL:', url);
+ notifyError('Could not process audio URL');
+ return;
+ }
+
+ // Set source and play
+ audioPlayer.src = playableUrl;
+ audioPlayer.load();
+
+ // Store current song info
+ currentSong = {
+ title: title,
+ url: url,
+ playableUrl: playableUrl
+ };
+
+ // Attempt to play
+ const playPromise = audioPlayer.play();
+
+ if (playPromise !== undefined) {
+ playPromise
+ .then(() => {
+ console.log('DJ System: Successfully started playing:', title);
+ isPlaying = true;
+ })
+ .catch(error => {
+ console.error('DJ System: Play failed:', error);
+ notifyError('Playback failed: ' + error.message);
+ });
+ }
+
+ } catch (error) {
+ console.error('DJ System: Error in playMusic:', error);
+ notifyError('Error playing music: ' + error.message);
+ }
+}
+
+// Process different URL types
+async function processUrl(url) {
+ try {
+ // Check if it's a YouTube URL
+ if (isYouTubeUrl(url)) {
+ console.log('DJ System: Processing YouTube URL');
+ return await convertYouTubeUrl(url);
+ }
+
+ // Check if it's a direct audio URL
+ if (isDirectAudioUrl(url)) {
+ console.log('DJ System: Direct audio URL detected');
+ return url;
+ }
+
+ // Try to use URL as-is (might be pre-converted)
+ console.log('DJ System: Using URL as-is');
+ return url;
+
+ } catch (error) {
+ console.error('DJ System: Error processing URL:', error);
+ return null;
+ }
+}
+
+// Check if URL is YouTube
+function isYouTubeUrl(url) {
+ const youtubePatterns = [
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
+ /youtube\.com\/watch\?.*v=([^&\n?#]+)/
+ ];
+
+ return youtubePatterns.some(pattern => pattern.test(url));
+}
+
+// Check if URL is direct audio
+function isDirectAudioUrl(url) {
+ const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
+ const lowerUrl = url.toLowerCase();
+
+ return audioExtensions.some(ext => lowerUrl.includes(ext)) ||
+ lowerUrl.includes('audio/') ||
+ lowerUrl.includes('stream');
+}
+
+// Convert YouTube URL (this would be handled server-side in real implementation)
+async function convertYouTubeUrl(url) {
+ try {
+ // Extract video ID
+ const videoId = extractYouTubeVideoId(url);
+ if (!videoId) {
+ throw new Error('Could not extract YouTube video ID');
+ }
+
+ console.log('DJ System: YouTube Video ID:', videoId);
+
+ // In a real implementation, this would call your server-side converter
+ // For now, we'll return the original URL and let the server handle it
+ return url;
+
+ } catch (error) {
+ console.error('DJ System: YouTube conversion error:', error);
+ return null;
+ }
+}
+
+// Extract YouTube video ID
+function extractYouTubeVideoId(url) {
+ const patterns = [
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
+ /youtube\.com\/watch\?.*v=([^&\n?#]+)/
+ ];
+
+ for (let pattern of patterns) {
+ const match = url.match(pattern);
+ if (match && match[1]) {
+ return match[1];
+ }
+ }
+
+ return null;
+}
+
+// Stop music
+function stopMusic() {
+ if (!audioPlayer) return;
+
+ try {
+ audioPlayer.pause();
+ audioPlayer.currentTime = 0;
+ audioPlayer.src = '';
+ isPlaying = false;
+ currentSong = null;
+
+ // Clear any fade effects
+ if (fadeInterval) {
+ clearInterval(fadeInterval);
+ fadeInterval = null;
+ }
+
+ console.log('DJ System: Music stopped');
+
+ } catch (error) {
+ console.error('DJ System: Error stopping music:', error);
+ }
+}
+
+// Pause music
+function pauseMusic() {
+ if (!audioPlayer || !isPlaying) return;
+
+ try {
+ audioPlayer.pause();
+ isPlaying = false;
+ console.log('DJ System: Music paused');
+ } catch (error) {
+ console.error('DJ System: Error pausing music:', error);
+ }
+}
+
+// Resume music
+function resumeMusic() {
+ if (!audioPlayer || isPlaying) return;
+
+ try {
+ const playPromise = audioPlayer.play();
+ if (playPromise !== undefined) {
+ playPromise
+ .then(() => {
+ isPlaying = true;
+ console.log('DJ System: Music resumed');
+ })
+ .catch(error => {
+ console.error('DJ System: Resume failed:', error);
+ });
+ }
+ } catch (error) {
+ console.error('DJ System: Error resuming music:', error);
+ }
+}
+
+// Set volume
+function setVolume(volume) {
+ if (!audioPlayer) return;
+
+ try {
+ currentVolume = Math.max(0, Math.min(100, volume));
+ audioPlayer.volume = currentVolume / 100;
+ console.log('DJ System: Volume set to', currentVolume + '%');
+ } catch (error) {
+ console.error('DJ System: Error setting volume:', error);
+ }
+}
+
+// Fade out effect (when player moves away)
+function fadeOut() {
+ if (!audioPlayer || !isPlaying) return;
+
+ if (fadeInterval) {
+ clearInterval(fadeInterval);
+ }
+
+ let currentVol = audioPlayer.volume;
+ const targetVol = 0;
+ const fadeStep = 0.05;
+
+ fadeInterval = setInterval(() => {
+ currentVol -= fadeStep;
+ if (currentVol <= targetVol) {
+ currentVol = targetVol;
+ audioPlayer.volume = currentVol;
+ clearInterval(fadeInterval);
+ fadeInterval = null;
+ } else {
+ audioPlayer.volume = currentVol;
+ }
+ }, 50);
+}
+
+// Fade in effect (when player moves closer)
+function fadeIn() {
+ if (!audioPlayer || !isPlaying) return;
+
+ if (fadeInterval) {
+ clearInterval(fadeInterval);
+ }
+
+ let currentVol = audioPlayer.volume;
+ const targetVol = currentVolume / 100;
+ const fadeStep = 0.05;
+
+ fadeInterval = setInterval(() => {
+ currentVol += fadeStep;
+ if (currentVol >= targetVol) {
+ currentVol = targetVol;
+ audioPlayer.volume = currentVol;
+ clearInterval(fadeInterval);
+ fadeInterval = null;
+ } else {
+ audioPlayer.volume = currentVol;
+ }
+ }, 50);
+}
+
+// Notify FiveM about errors
+function notifyError(message) {
+ fetch(`https://${GetParentResourceName()}/audioError`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify({
+ error: message
+ })
+ }).catch(err => {
+ console.error('DJ System: Failed to notify error:', err);
+ });
+}
+
+// Get current resource name
+function GetParentResourceName() {
+ return window.location.hostname;
+}
+
+// Debug functions (can be called from browser console)
+window.djDebug = {
+ getCurrentSong: () => currentSong,
+ getVolume: () => currentVolume,
+ isPlaying: () => isPlaying,
+ getAudioPlayer: () => audioPlayer,
+ testPlay: (url) => playMusic(url, 50, 'Test Song'),
+ testStop: () => stopMusic(),
+ testVolume: (vol) => setVolume(vol)
+};
+
+// Log when script is loaded
+console.log('DJ System: Script loaded and ready');
diff --git a/resources/[tools]/nordi_dj/html/style.css b/resources/[tools]/nordi_dj/html/style.css
new file mode 100644
index 000000000..fbc090dd9
--- /dev/null
+++ b/resources/[tools]/nordi_dj/html/style.css
@@ -0,0 +1,72 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ background: transparent;
+ font-family: 'Arial', sans-serif;
+ overflow: hidden;
+}
+
+#music-player {
+ position: absolute;
+ top: -9999px;
+ left: -9999px;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+#audio-player {
+ width: 100%;
+ height: auto;
+}
+
+/* Optional progress bar styles */
+#progress-container {
+ width: 300px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+#progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
+ width: 0%;
+ transition: width 0.1s ease;
+}
+
+/* Optional song info styles */
+#song-info {
+ background: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 10px;
+ border-radius: 5px;
+ font-size: 12px;
+}
+
+#song-title {
+ font-weight: bold;
+ margin-right: 10px;
+}
+
+#song-time {
+ opacity: 0.7;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ #progress-container {
+ width: 250px;
+ }
+
+ #song-info {
+ font-size: 11px;
+ padding: 8px;
+ }
+}
diff --git a/resources/[tools]/nordi_dj/server/main.lua b/resources/[tools]/nordi_dj/server/main.lua
new file mode 100644
index 000000000..6fac67443
--- /dev/null
+++ b/resources/[tools]/nordi_dj/server/main.lua
@@ -0,0 +1,263 @@
+local QBCore = exports['qb-core']:GetCoreObject()
+
+-- YouTube URL Converter
+local function ConvertYouTubeUrl(url, callback)
+ if not string.match(url, "youtube%.com") and not string.match(url, "youtu%.be") then
+ callback(url) -- Nicht YouTube, direkt zurückgeben
+ return
+ end
+
+ -- Extrahiere Video ID
+ local videoId = ExtractYouTubeVideoId(url)
+ if not videoId then
+ callback(nil)
+ return
+ end
+
+ if Config.LocalYouTubeConverter.enabled then
+ -- Verwende lokalen Converter
+ PerformHttpRequest(Config.LocalYouTubeConverter.serverUrl, function(errorCode, resultData, resultHeaders)
+ if errorCode == 200 then
+ local data = json.decode(resultData)
+ if data and data.url then
+ callback(data.url)
+ else
+ callback(nil)
+ end
+ else
+ callback(nil)
+ end
+ end, 'POST', json.encode({videoId = videoId}), {['Content-Type'] = 'application/json'})
+
+ elseif Config.YouTubeAPI.enabled then
+ -- Verwende externe API
+ local apiUrl = Config.YouTubeAPI.apiUrl .. "?id=" .. videoId
+
+ PerformHttpRequest(apiUrl, function(errorCode, resultData, resultHeaders)
+ if errorCode == 200 then
+ local data = json.decode(resultData)
+ if data and data.link then
+ callback(data.link)
+ else
+ callback(nil)
+ end
+ else
+ print("YouTube API Error: " .. errorCode)
+ callback(nil)
+ end
+ end, 'GET', '', Config.YouTubeAPI.headers)
+ else
+ callback(nil)
+ end
+end
+
+function ExtractYouTubeVideoId(url)
+ -- YouTube URL Patterns
+ local patterns = {
+ "youtube%.com/watch%?v=([%w%-_]+)",
+ "youtube%.com/watch%?.*&v=([%w%-_]+)",
+ "youtu%.be/([%w%-_]+)",
+ "youtube%.com/embed/([%w%-_]+)"
+ }
+
+ for _, pattern in ipairs(patterns) do
+ local videoId = string.match(url, pattern)
+ if videoId then
+ return videoId
+ end
+ end
+
+ return nil
+end
+
+-- Database Setup
+CreateThread(function()
+ MySQL.query([[
+ CREATE TABLE IF NOT EXISTS dj_playlists (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ owner VARCHAR(50) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ]])
+
+ MySQL.query([[
+ CREATE TABLE IF NOT EXISTS dj_playlist_songs (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ playlist_id INT NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ url TEXT NOT NULL,
+ converted_url TEXT NULL,
+ position INT DEFAULT 0,
+ FOREIGN KEY (playlist_id) REFERENCES dj_playlists(id) ON DELETE CASCADE
+ )
+ ]])
+
+ MySQL.query([[
+ CREATE TABLE IF NOT EXISTS dj_url_cache (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ original_url VARCHAR(500) NOT NULL UNIQUE,
+ converted_url TEXT NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ]])
+end)
+
+-- Events
+RegisterNetEvent('dj:server:playMusic', function(data)
+ local src = source
+
+ -- Prüfe Cache zuerst
+ MySQL.query('SELECT converted_url FROM dj_url_cache WHERE original_url = ? AND expires_at > NOW()', {data.url}, function(cached)
+ if cached[1] then
+ -- Verwende gecachte URL
+ local musicData = {
+ title = data.title,
+ url = cached[1].converted_url,
+ volume = data.volume,
+ booth = data.booth,
+ range = data.range
+ }
+ TriggerClientEvent('dj:client:playMusic', -1, musicData)
+ print(('[DJ System] %s spielt Musik ab (cached): %s'):format(GetPlayerName(src), data.title))
+ else
+ -- Konvertiere URL
+ ConvertYouTubeUrl(data.url, function(convertedUrl)
+ if convertedUrl then
+ -- Cache die URL für 1 Stunde
+ MySQL.insert('INSERT INTO dj_url_cache (original_url, converted_url, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 1 HOUR)) ON DUPLICATE KEY UPDATE converted_url = VALUES(converted_url), expires_at = VALUES(expires_at)', {
+ data.url,
+ convertedUrl
+ })
+
+ local musicData = {
+ title = data.title,
+ url = convertedUrl,
+ volume = data.volume,
+ booth = data.booth,
+ range = data.range
+ }
+ TriggerClientEvent('dj:client:playMusic', -1, musicData)
+ print(('[DJ System] %s spielt Musik ab (converted): %s'):format(GetPlayerName(src), data.title))
+ else
+ TriggerClientEvent('dj:client:notify', src, 'Fehler beim Konvertieren der YouTube URL!', 'error')
+ print(('[DJ System] Fehler beim Konvertieren der URL: %s'):format(data.url))
+ end
+ end)
+ end
+ end)
+end)
+
+RegisterNetEvent('dj:server:stopMusic', function(booth)
+ local src = source
+ TriggerClientEvent('dj:client:stopMusic', -1)
+ print(('[DJ System] %s hat die Musik gestoppt'):format(GetPlayerName(src)))
+end)
+
+RegisterNetEvent('dj:server:setVolume', function(data)
+ local src = source
+ TriggerClientEvent('dj:client:setVolume', -1, data.volume)
+ print(('[DJ System] %s hat die Lautstärke auf %d%% gesetzt'):format(GetPlayerName(src), data.volume))
+end)
+
+RegisterNetEvent('dj:server:getPlaylists', function()
+ local src = source
+ local Player = QBCore.Functions.GetPlayer(src)
+ if not Player then return end
+
+ MySQL.query('SELECT * FROM dj_playlists WHERE owner = ?', {Player.PlayerData.citizenid}, function(playlists)
+ local playlistData = {}
+
+ for _, playlist in pairs(playlists) do
+ MySQL.query('SELECT * FROM dj_playlist_songs WHERE playlist_id = ? ORDER BY position', {playlist.id}, function(songs)
+ table.insert(playlistData, {
+ id = playlist.id,
+ name = playlist.name,
+ songs = songs
+ })
+
+ if #playlistData == #playlists then
+ TriggerClientEvent('dj:client:updatePlaylists', src, playlistData)
+ end
+ end)
+ end
+
+ if #playlists == 0 then
+ TriggerClientEvent('dj:client:updatePlaylists', src, {})
+ end
+ end)
+end)
+
+RegisterNetEvent('dj:server:createPlaylist', function(name)
+ local src = source
+ local Player = QBCore.Functions.GetPlayer(src)
+ if not Player then return end
+
+ MySQL.insert('INSERT INTO dj_playlists (name, owner) VALUES (?, ?)', {
+ name,
+ Player.PlayerData.citizenid
+ }, function(id)
+ if id then
+ TriggerClientEvent('dj:client:notify', src, 'Playlist "' .. name .. '" wurde erstellt!', 'success')
+ TriggerEvent('dj:server:getPlaylists')
+ else
+ TriggerClientEvent('dj:client:notify', src, 'Fehler beim Erstellen der Playlist!', 'error')
+ end
+ end)
+end)
+
+RegisterNetEvent('dj:server:addSongToPlaylist', function(playlistId, song)
+ local src = source
+ local Player = QBCore.Functions.GetPlayer(src)
+ if not Player then return end
+
+ MySQL.query('SELECT * FROM dj_playlists WHERE id = ? AND owner = ?', {
+ playlistId,
+ Player.PlayerData.citizenid
+ }, function(result)
+ if result[1] then
+ MySQL.insert('INSERT INTO dj_playlist_songs (playlist_id, title, url) VALUES (?, ?, ?)', {
+ playlistId,
+ song.title,
+ song.url
+ }, function(id)
+ if id then
+ TriggerClientEvent('dj:client:notify', src, 'Song wurde zur Playlist hinzugefügt!', 'success')
+ TriggerEvent('dj:server:getPlaylists')
+ else
+ TriggerClientEvent('dj:client:notify', src, 'Fehler beim Hinzufügen des Songs!', 'error')
+ end
+ end)
+ else
+ TriggerClientEvent('dj:client:notify', src, 'Du hast keine Berechtigung für diese Playlist!', 'error')
+ end
+ end)
+end)
+
+RegisterNetEvent('dj:server:deletePlaylist', function(playlistId)
+ local src = source
+ local Player = QBCore.Functions.GetPlayer(src)
+ if not Player then return end
+
+ MySQL.query('DELETE FROM dj_playlists WHERE id = ? AND owner = ?', {
+ playlistId,
+ Player.PlayerData.citizenid
+ }, function(affectedRows)
+ if affectedRows > 0 then
+ TriggerClientEvent('dj:client:notify', src, 'Playlist wurde gelöscht!', 'success')
+ TriggerEvent('dj:server:getPlaylists')
+ else
+ TriggerClientEvent('dj:client:notify', src, 'Fehler beim Löschen der Playlist!', 'error')
+ end
+ end)
+end)
+
+-- Cache Cleanup (läuft alle 30 Minuten)
+CreateThread(function()
+ while true do
+ Wait(1800000) -- 30 Minuten
+ MySQL.query('DELETE FROM dj_url_cache WHERE expires_at < NOW()')
+ print('[DJ System] Cache bereinigt')
+ end
+end)