diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e9a4fc2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' # Trigger on any tag starting with 'v', like v1.0, v2.3.1, etc. + +jobs: + build: + name: Build and Release + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python 3.12.6 + uses: actions/setup-python@v4 + with: + python-version: '3.12.6' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Get version from tag + id: get_version + run: | + $VERSION = "${{ github.ref_name }}" + $VERSION = $VERSION -replace "^v", "" + echo "version=$VERSION" >> $env:GITHUB_OUTPUT + shell: pwsh + + - name: Build application + run: | + python build.py build + + - name: Build installer + run: | + python build.py installer + + - name: Create release package + run: | + python build.py package + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: release-artifacts + path: | + dist/VLC Discord Presence.exe + dist/VLC Discord RP Setup.exe + VLC_Discord_RP.zip + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + name: VLC Discord Rich Presence ${{ steps.get_version.outputs.version }} + body: | + # VLC Discord Rich Presence ${{ steps.get_version.outputs.version }} + + ## Installation + Download and run the installer (`VLC Discord RP Setup.exe`), or use the standalone application. + + ## Requirements + - Windows + - VLC Media Player + - Discord + files: | + dist/VLC Discord RP Setup.exe + VLC_Discord_RP.zip + draft: false + prerelease: false \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ced7fb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec +!vlc_discord_rp.spec + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/* +!.vscode/settings.json +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Project specific +release/ +VLC_Discord_RP.zip \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e99b4e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "always" + }, + "python.analysis.extraPaths": [ + "${workspaceFolder}" + ], + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/*.pyc": true + }, + "terminal.integrated.env.windows": { + "PYTHONPATH": "${workspaceFolder}" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1c08cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Valentin Marquez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..48f4355 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# VLC Discord Rich Presence +
+ VLC Discord RP Logo +
+ +> *"Because your friends totally need to know you're watching Shrek for the 17th time."* + +## What Is This Thing? + +VLC Discord Rich Presence is a magical little bridge between your VLC Media Player and Discord, letting your friends see what media you're currently enjoying (or enduring). It shows: + +- What you're watching/listening to +- Artist and album info (for music) +- Play/pause status +- Fancy icons and progress bars + +Built during a questionable 10-hour coding marathon fueled by caffeine and poor life decisions (1AM-11AM), this app works surprisingly well despite its sleep-deprived origins! + +## Installation + +### The Easy Way + +1. Download the latest installer from the [releases page](https://github.com/valentin-marquez/vlc-discord-rp/releases) +2. Run the installer (`VLC Discord RP Setup.exe`) +3. Follow the on-screen instructions +4. Click "Install Now" +5. Let the installer do its magic ✨ +6. Enjoy your new Discord flex powers! + +### The Hard Way (For Developers and Masochists) + +1. Clone this repo +2. Install requirements: `pip install -r requirements.txt` +3. Run `python build.py all` to build both the app and installer +4. Look in the `dist` folder for your shiny new executables + +## How It Works + +1. The extension adds a Lua script to VLC that tracks what you're playing +2. This script creates and continuously updates a file at `%APPDATA%\vlc\vlc_discord_status.json` with your current media information +3. The background application reads this JSON file and relays the information to Discord's Rich Presence API +4. Your friends wonder why you're watching cooking videos at 3 AM +5. Social validation achieved! 🎉 + +## FAQ + +### Q: Why isn't anything showing up in Discord? +**A:** Make sure: +1. Both VLC and Discord are running +2. The extension is activated in VLC (View -> Discord RP) +3. You're playing a media file +4. The app was added to Windows startup during installation (it needs to be running to work) +5. Due to VLC limitations, you may need to re-enable the extension when you first open VLC (not needed when switching media files while VLC remains open) +6. If it's enabled but still not showing, try forcing an update by clicking View -> Discord RP -> Update Now + +You can check the VLC console (Ctrl+M) for any errors. If issues persist, please report them in the GitHub Issues section. + +### Q: How do I close/exit the app? +**A:** The app runs silently in the background. To close it, find `VLC Discord Presence.exe` in Task Manager and end the process. Yes, I know, very user-friendly. I was sleepy, okay? + +### Q: Where is it installed? +**A:** By default, the app installs to `%LOCALAPPDATA%\VLC Discord RP`. The Lua extension goes to `%APPDATA%\vlc\lua\extensions`. + +### Q: Does it work on Mac/Linux? +**A:** Currently tested on Windows only. Could work elsewhere with manual installation, but you're on your own there, brave explorer. + +### Q: How much of my privacy am I giving up? +**A:** Only the title, artist, and album info from your media files are shared - and only with your Discord friends. We don't collect any data. Also, you can pause the sharing by closing VLC. + +### Q: I found a bug! +**A:** That's not a question, but I respect your enthusiasm. Please open an issue on GitHub with details about what went wrong. + +### Q: Why did you make this? +**A:** The voices told me to. Also, it seemed like a fun project. + + + +## Development Notes + +This project was created in one sleepless night from 1AM to 11AM, which explains: +- Some questionable code decisions +- The lack of proper error handling in places +- Why the uninstaller is a batch file (it just works™) +- The dependency on Task Manager to close it + +Pull requests are welcome if you want to improve things while I catch up on sleep! + +## License + +MIT License - Feel free to use, modify, and distribute as you see fit. + +--- + +*Made with ❤️ and sleep deprivation* + +*"It works on my machine!"* - Developer motto \ No newline at end of file diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..2a65822 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/vlc_ios.png b/assets/vlc_ios.png new file mode 100644 index 0000000..b55b8b8 Binary files /dev/null and b/assets/vlc_ios.png differ diff --git a/build.py b/build.py new file mode 100644 index 0000000..0332eae --- /dev/null +++ b/build.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +import argparse +import os +import shutil +import subprocess + + +def clean(): + """Clean build artifacts""" + print("Cleaning build directories...") + dirs_to_clean = ["build", "dist", "__pycache__", "release", "VLC_Discord_RP"] + + for dir_name in dirs_to_clean: + if os.path.exists(dir_name): + shutil.rmtree(dir_name) + + # Clean .pyc files + for root, dirs, files in os.walk("."): + for file in files: + if file.endswith(".pyc"): + os.remove(os.path.join(root, file)) + + # Remove zip file if it exists + zip_file = "VLC_Discord_RP.zip" + if os.path.exists(zip_file): + os.remove(zip_file) + print(f"Removed {zip_file}") + + print("Clean completed") + + +def build_app(): + """Build the VLC Discord RP application executable""" + print("Building VLC Discord Rich Presence application...") + + # Run PyInstaller for the main application + subprocess.run(["pyinstaller", "--clean", "vlc_discord_rp.spec"], check=True) + + print("Application build complete!") + + +def build_installer(): + """Build the installer executable""" + print("Building installer...") + + # First make sure the application was built + if not os.path.exists(os.path.join("dist", "VLC Discord Presence.exe")): + print("Error: Application executable not found. Run build first.") + return False + + # Create installer spec + installer_spec = """# -*- mode: python -*- +a = Analysis( + ['scripts/installer.py'], + pathex=[], + binaries=[], + datas=[ + ('dist/VLC Discord Presence.exe', '.'), + ('assets', 'assets'), + ('lua', 'lua'), + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + noarchive=False +) + +pyz = PYZ(a.pure, a.zipped_data) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='VLC Discord RP Setup', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='assets/icon.ico', +) +""" + + # Write the installer spec file + with open("installer.spec", "w") as spec_file: + spec_file.write(installer_spec) + + # Run PyInstaller for the installer + subprocess.run(["pyinstaller", "--clean", "installer.spec"], check=True) + + # Cleanup + os.remove("installer.spec") + + print("Installer build complete!") + return True + + +def package(): + """Package everything into a release zip""" + print("Creating release package...") + + # Create release directory + release_dir = "release" + if os.path.exists(release_dir): + shutil.rmtree(release_dir) + os.makedirs(release_dir) + + # Copy installer + installer_path = os.path.join("dist", "VLC Discord RP Setup.exe") + if os.path.exists(installer_path): + shutil.copy2( + installer_path, os.path.join(release_dir, "VLC Discord RP Setup.exe") + ) + else: + print("Warning: Installer executable not found.") + + # Copy readme and license + if os.path.exists("README.md"): + shutil.copy2("README.md", os.path.join(release_dir, "README.md")) + + if os.path.exists("LICENSE"): + shutil.copy2("LICENSE", os.path.join(release_dir, "LICENSE")) + + # Create ZIP archive + shutil.make_archive("VLC_Discord_RP", "zip", release_dir) + print("Release package created: VLC_Discord_RP.zip") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Build VLC Discord Rich Presence") + parser.add_argument( + "command", + choices=["clean", "build", "installer", "package", "all"], + help="Build command to run", + ) + + args = parser.parse_args() + + if args.command == "clean": + clean() + elif args.command == "build": + build_app() + elif args.command == "installer": + build_installer() + elif args.command == "package": + package() + elif args.command == "all": + clean() + build_app() + if build_installer(): + package() + else: + parser.print_help() diff --git a/lua/discord-rp.lua b/lua/discord-rp.lua new file mode 100644 index 0000000..bcfd95f --- /dev/null +++ b/lua/discord-rp.lua @@ -0,0 +1,323 @@ +local presence_active = false +local status_file_path = nil +local debug_mode = true + +function debug_log(message) + if debug_mode then + vlc.msg.info("Discord RP Debug: " .. tostring(message)) + end +end + +function descriptor() + return { + title = "Discord Rich Presence", + version = "1.1.0", + author = "Valentin Marquez", + url = 'https://github.com/valentin-marquez/vlc-discord-rp', + shortdesc = "Discord RP", + description = "Display your currently playing media in Discord Rich Presence", + capabilities = {"menu","input-listener", "meta-listener", "playing-listener", "playlist-listener"} + } +end + +function setup_status_file() + debug_log("Setting up status file") + + local cache_dir = "" + local ok, dir = pcall(function() + return vlc.config.cachedir() + end) + + if ok and dir then + cache_dir = dir + else + ok, dir = pcall(function() + return vlc.config.userdatadir() + end) + if ok and dir then + cache_dir = dir + else + if string.match(package.config, "\\") then + cache_dir = os.getenv("TEMP") or "C:\\Temp" + else + cache_dir = "/tmp" + end + end + end + + if not string.match(cache_dir, "[/\\]$") then + cache_dir = cache_dir .. "/" + end + + cache_dir = string.gsub(cache_dir, "\\", "/") + + status_file_path = cache_dir .. "vlc_discord_status.json" + vlc.msg.info("Discord RP: Status file at " .. status_file_path) + + local test_ok, test_err = pcall(function() + local file = io.open(status_file_path, "w") + if not file then + return false, "Could not open file for writing" + end + file:write("{\"test\": true}") + file:close() + return true + end) + + if not test_ok then + vlc.msg.err("Discord RP: Error testing file write: " .. tostring(test_err)) + return false + end + + return true +end + +function activate() + vlc.msg.info("Discord RP: Attempting to activate") + + local vlc_version + pcall(function() + if vlc.misc and vlc.misc.version then + vlc_version = vlc.misc.version() + else + vlc_version = "unknown" + end + end) + vlc.msg.info("Discord RP: Running on VLC version " .. tostring(vlc_version)) + + local setup_ok, setup_err = pcall(setup_status_file) + if not setup_ok then + vlc.msg.err("Discord RP: Error in setup_status_file: " .. tostring(setup_err)) + return false + end + + presence_active = true + + local write_ok, write_err = pcall(write_status, { + active = true, + status = "idle", + timestamp = os.time() + }) + + if not write_ok then + vlc.msg.err("Discord RP: Error writing initial status: " .. tostring(write_err)) + return false + end + + vlc.msg.info("Discord RP: Successfully activated") + return true +end + +function deactivate() + vlc.msg.info("Discord RP deactivated") + presence_active = false + + pcall(write_status, { + active = false, + timestamp = os.time() + }) +end + +function menu() + return {"Update now", "About"} +end + +function trigger_menu(id) + if id == 1 then + update_presence() + elseif id == 2 then + show_credits() + end +end + +function show_credits() + local d = vlc.dialog("About") + d:add_label("Discord Rich Presence for VLC", 1, 1, 3, 1) + d:add_label("by Valentin Marquez", 1, 2, 3, 1) + d:add_label("Version 1.1.0", 1, 2, 3, 1) + d:show() +end + +function write_status(status) + if not status_file_path then + vlc.msg.err("Discord RP: Status file path not defined") + return false + end + + debug_log("Writing status: " .. tostring(status.status or "nil")) + + local file, err = io.open(status_file_path, "w") + if not file then + vlc.msg.err("Discord RP: Error opening file: " .. tostring(err)) + return false + end + + local function table_to_json(t, indent) + indent = indent or 0 + local spaces = string.rep(" ", indent) + local json = "{\n" + + local keys = {} + for k in pairs(t) do + table.insert(keys, k) + end + + for i, k in ipairs(keys) do + local v = t[k] + json = json .. spaces .. " \"" .. k .. "\": " + + if type(v) == "table" then + json = json .. table_to_json(v, indent + 1) + elseif type(v) == "string" then + json = json .. "\"" .. v:gsub('"', '\\"') .. "\"" + elseif type(v) == "number" or type(v) == "boolean" then + json = json .. tostring(v) + else + json = json .. "\"" .. tostring(v) .. "\"" + end + + if i < #keys then + json = json .. ",\n" + else + json = json .. "\n" + end + end + + if json:sub(-2) == ",\n" then + json = json:sub(1, -3) .. "\n" + end + + return json .. spaces .. "}" + end + + local write_ok, write_err = pcall(function() + file:write(table_to_json(status)) + file:close() + end) + + if not write_ok then + vlc.msg.err("Discord RP: Error writing: " .. tostring(write_err)) + return false + end + + return true +end + +function update_presence() + debug_log("Updating presence") + + if not presence_active then + return + end + + if not vlc.input.is_playing() then + write_status({ + active = false, + status = "stopped", + timestamp = os.time() + }) + return + end + + local input_item = nil + local success = pcall(function() + input_item = vlc.input.item() + input_item = vlc.input.item() + end) + + if not success or not input_item then + debug_log("Could not retrieve playback item") + write_status({ + active = true, + status = "idle", + timestamp = os.time() + }) + return + end + + local title = input_item:name() or "Unknown" + local metas = input_item:metas() or {} + local artist = metas["artist"] or "" + local album = metas["album"] or "" + + local duration = input_item:duration() or 0 + + local input = vlc.object.input() + local is_playing = true + local position = 0 + local rate = 1.0 + local audio_delay = 0 + + if input then + local state = vlc.var.get(input, "state") + is_playing = (state == 2) + + position = math.floor(vlc.var.get(input, "time") / 1000000) + rate = vlc.var.get(input, "rate") + audio_delay = vlc.var.get(input, "audio-delay") / 1000000 + end + + local now_playing = { + title = title, + artist = artist, + album = album, + genre = metas["genre"] or "", + track_number = metas["track_number"] or "", + date = metas["date"] or "" + } + + local format = "" + local info = input_item:info() or {} + if info["General"] then + for k, v in pairs(info["General"]) do + if k:lower():match("format") then + format = v + break + end + end + end + + local status = { + active = true, + status = is_playing and "playing" or "paused", + media = { + title = title, + artist = artist, + album = album, + genre = now_playing.genre, + track_number = now_playing.track_number, + year = now_playing.date, + format = format + }, + playback = { + duration = math.floor(duration), + position = position, + remaining = math.floor(duration) - position, + rate = rate, + audio_delay = audio_delay + }, + timestamp = os.time() + } + + write_status(status) +end + +function input_changed() + debug_log("input_changed event") + update_presence() +end + +function meta_changed() + debug_log("meta_changed event") + update_presence() +end + +function playing_changed() + debug_log("playing_changed event") + update_presence() +end + +function playlist_changed() + debug_log("playlist_changed event") + update_presence() +end diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cec6f9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pypresence @ git+https://github.com/qwertyquerty/pypresence.git +pyinstaller==6.1.0 \ No newline at end of file diff --git a/scripts/installer.py b/scripts/installer.py new file mode 100644 index 0000000..7f1f8c8 --- /dev/null +++ b/scripts/installer.py @@ -0,0 +1,416 @@ +import ctypes +import os +import shutil +import subprocess +import sys +import tkinter as tk +import winreg +from tkinter import filedialog, messagebox, ttk + + +def is_admin(): + """Check if the script is running with admin privileges""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + return False + + +def resource_path(relative_path): + """Get absolute path to resource, works for dev and PyInstaller""" + base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base_path, relative_path) + + +def find_vlc_path(): + """Find VLC installation path from registry""" + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VideoLAN\VLC") as key: + return winreg.QueryValueEx(key, "InstallDir")[0] + except WindowsError: + # Try 32-bit registry view on 64-bit Windows + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\VideoLAN\VLC" + ) as key: + return winreg.QueryValueEx(key, "InstallDir")[0] + except WindowsError: + pass + + # Try common paths + common_paths = [ + r"C:\Program Files\VideoLAN\VLC", + r"C:\Program Files (x86)\VideoLAN\VLC", + ] + + for path in common_paths: + if os.path.exists(path): + return path + + return None + + +def get_extension_dir(): + """Get VLC extension directory""" + + appdata_path = os.path.join( + os.environ.get("APPDATA", ""), "vlc", "lua", "extensions" + ) + + if not os.path.exists(appdata_path): + os.makedirs(appdata_path, exist_ok=True) + + return appdata_path + + +def add_to_startup(exe_path): + """Add to Windows startup""" + startup_folder = os.path.join( + os.environ["APPDATA"], r"Microsoft\Windows\Start Menu\Programs\Startup" + ) + + shortcut_path = os.path.join(startup_folder, "VLC Discord Presence.lnk") + + ps_script = f""" + $WshShell = New-Object -ComObject WScript.Shell + $Shortcut = $WshShell.CreateShortcut("{shortcut_path}") + $Shortcut.TargetPath = "{exe_path}" + $Shortcut.WorkingDirectory = "{os.path.dirname(exe_path)}" + $Shortcut.Description = "VLC Discord Rich Presence" + $Shortcut.IconLocation = "{exe_path},0" + $Shortcut.Save() + """ + + subprocess.run( + ["powershell", "-Command", ps_script], capture_output=True, text=True + ) + + return os.path.exists(shortcut_path) + + +def create_uninstaller( + install_dir, extension_path, exe_name="VLC Discord Presence.exe" +): + """Create an uninstaller batch file""" + uninstall_script = os.path.join(install_dir, "uninstall.bat") + startup_shortcut = os.path.join( + os.environ["APPDATA"], + r"Microsoft\Windows\Start Menu\Programs\Startup", + "VLC Discord Presence.lnk", + ) + + with open(uninstall_script, "w") as f: + f.write("@echo off\n") + f.write("echo Uninstalling VLC Discord Rich Presence...\n") + f.write('taskkill /f /im "%s" >nul 2>&1\n' % exe_name) + f.write("timeout /t 1 /nobreak >nul\n") + f.write('del "%s" >nul 2>&1\n' % startup_shortcut) + f.write('del "%s" >nul 2>&1\n' % extension_path) + f.write('rmdir /s /q "%s" >nul 2>&1\n' % install_dir) + f.write("echo Uninstallation complete\n") + f.write("pause\n") + + f.write('(goto) 2>nul & del "%~f0"\n') + + return uninstall_script + + +class InstallerGUI: + def __init__(self): + self.root = tk.Tk() + self.root.title("VLC Discord Rich Presence Installer") + self.root.geometry("600x500") + self.root.resizable(False, False) + + try: + icon_path = resource_path(os.path.join("assets", "icon.ico")) + if os.path.exists(icon_path): + self.root.iconbitmap(icon_path) + except Exception as e: + print(f"Error setting icon: {e}") + + self.style = ttk.Style() + self.style.configure("TButton", padding=6, relief="flat", font=("Segoe UI", 10)) + self.style.configure("TLabel", font=("Segoe UI", 10)) + self.style.configure("Header.TLabel", font=("Segoe UI", 14, "bold")) + + self.style.configure( + "Action.TButton", padding=10, font=("Segoe UI", 10, "bold") + ) + + self.create_widgets() + + self.root.update_idletasks() + width = self.root.winfo_width() + height = self.root.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + self.root.geometry(f"{width}x{height}+{x}+{y}") + + self.root.update_idletasks() + + def create_widgets(self): + """Create GUI widgets""" + + main_frame = ttk.Frame(self.root) + main_frame.pack(fill="both", expand=True) + + header_frame = ttk.Frame(main_frame, padding="20 20 20 10") + header_frame.pack(fill="x") + + ttk.Label( + header_frame, text="VLC Discord Rich Presence", style="Header.TLabel" + ).pack(side="left") + + ttk.Label( + header_frame, + text="This wizard will install VLC Discord Rich Presence on your computer.", + wraplength=550, + ).pack(side="top", anchor="w", pady=(25, 0)) + + dir_frame = ttk.LabelFrame( + main_frame, text="Installation Directory", padding="20 10" + ) + dir_frame.pack(fill="x", padx=20) + + self.install_dir = tk.StringVar() + self.install_dir.set( + os.path.join(os.environ.get("LOCALAPPDATA", ""), "VLC Discord RP") + ) + + dir_entry = ttk.Entry(dir_frame, textvariable=self.install_dir, width=50) + dir_entry.pack(side="left", padx=(0, 10), fill="x", expand=True) + + dir_btn = ttk.Button( + dir_frame, text="Browse...", command=self.browse_install_dir + ) + dir_btn.pack(side="right") + + vlc_frame = ttk.LabelFrame(main_frame, text="VLC Installation", padding="20 10") + vlc_frame.pack(fill="x", padx=20, pady=(20, 0)) + + self.vlc_dir = tk.StringVar() + vlc_path = find_vlc_path() + if vlc_path: + self.vlc_dir.set(vlc_path) + + vlc_entry = ttk.Entry(vlc_frame, textvariable=self.vlc_dir, width=50) + vlc_entry.pack(side="left", padx=(0, 10), fill="x", expand=True) + + vlc_btn = ttk.Button(vlc_frame, text="Browse...", command=self.browse_vlc_dir) + vlc_btn.pack(side="right") + + options_frame = ttk.LabelFrame(main_frame, text="Options", padding="20 10") + options_frame.pack(fill="x", padx=20, pady=(20, 0)) + + startup_frame = ttk.Frame(options_frame) + startup_frame.pack(fill="x", anchor="w") + + self.add_startup = tk.BooleanVar(value=True) + startup_cb = ttk.Checkbutton( + startup_frame, text="Add to Windows startup", variable=self.add_startup + ) + startup_cb.pack(side="left") + + ttk.Label( + startup_frame, + text="(Recommended - Required for automatic start with VLC)", + foreground="#0066CC", + font=("Segoe UI", 9, "italic"), + ).pack(side="left", padx=(5, 0)) + + self.create_shortcut = tk.BooleanVar(value=True) + ttk.Checkbutton( + options_frame, text="Create desktop shortcut", variable=self.create_shortcut + ).pack(anchor="w") + + self.status_var = tk.StringVar() + self.status_var.set("Ready to install") + + status_frame = ttk.Frame(main_frame, padding="20 10") + status_frame.pack(fill="x", padx=20, pady=(20, 0)) + + ttk.Label(status_frame, textvariable=self.status_var, foreground="gray").pack( + anchor="w" + ) + + self.progress = ttk.Progressbar(main_frame, length=560, mode="determinate") + self.progress.pack(pady=(10, 0), padx=20) + + btn_frame = ttk.Frame(main_frame, padding="20") + btn_frame.pack(fill="x", side="bottom", pady=(20, 0)) + + install_btn = ttk.Button( + btn_frame, text="Install Now", command=self.install, style="Action.TButton" + ) + install_btn.pack(side="right") + + ttk.Button(btn_frame, text="Cancel", command=self.root.destroy).pack( + side="right", padx=10 + ) + + def browse_install_dir(self): + directory = filedialog.askdirectory( + initialdir=self.install_dir.get(), title="Select Installation Directory" + ) + if directory: + self.install_dir.set(directory) + + def browse_vlc_dir(self): + directory = filedialog.askdirectory( + initialdir=self.vlc_dir.get() if self.vlc_dir.get() else "/", + title="Select VLC Installation Directory", + ) + if directory: + self.vlc_dir.set(directory) + + def update_progress(self, value, message): + self.progress["value"] = value + self.status_var.set(message) + self.root.update_idletasks() + + def install(self): + + install_dir = self.install_dir.get() + vlc_dir = self.vlc_dir.get() + + if not os.path.exists(vlc_dir): + messagebox.showerror("Error", "VLC installation directory not found.") + return + + try: + + self.update_progress(10, "Creating installation directory...") + os.makedirs(install_dir, exist_ok=True) + + self.update_progress(20, "Installing main application...") + exe_path = os.path.join(install_dir, "VLC Discord Presence.exe") + + bundled_exe = resource_path("VLC Discord Presence.exe") + if os.path.exists(bundled_exe): + + shutil.copy2(bundled_exe, exe_path) + else: + + shutil.copy2(sys.executable, exe_path) + + self.update_progress(30, "Installing assets...") + assets_dir = os.path.join(install_dir, "assets") + os.makedirs(assets_dir, exist_ok=True) + + assets_source = resource_path("assets") + if os.path.isdir(assets_source): + for file in os.listdir(assets_source): + src_file = os.path.join(assets_source, file) + dst_file = os.path.join(assets_dir, file) + if os.path.isfile(src_file): + shutil.copy2(src_file, dst_file) + + self.update_progress(50, "Installing VLC extension...") + extension_dir = get_extension_dir() + lua_source = resource_path(os.path.join("lua", "discord-rp.lua")) + extension_path = os.path.join(extension_dir, "discord-rp.lua") + + shutil.copy2(lua_source, extension_path) + + if self.add_startup.get(): + self.update_progress(70, "Adding to startup...") + if not add_to_startup(exe_path): + messagebox.showwarning( + "Warning", + "Could not add to startup. You may need to do this manually.", + ) + + if self.create_shortcut.get(): + self.update_progress(80, "Creating desktop shortcut...") + + # Use Windows shell special folders to reliably get desktop path + # This works with OneDrive redirection and localized folder names + ps_get_desktop = """ + [Environment]::GetFolderPath("Desktop") + """ + + try: + + desktop_result = subprocess.run( + ["powershell", "-Command", ps_get_desktop], + capture_output=True, + text=True, + check=True, + ) + + desktop_path = desktop_result.stdout.strip() + + if desktop_path and os.path.exists(desktop_path): + shortcut_path = os.path.join( + desktop_path, "VLC Discord Presence.lnk" + ) + + ps_script = f""" + $WshShell = New-Object -ComObject WScript.Shell + $Shortcut = $WshShell.CreateShortcut("{shortcut_path}") + $Shortcut.TargetPath = "{exe_path}" + $Shortcut.WorkingDirectory = "{install_dir}" + $Shortcut.Description = "VLC Discord Rich Presence" + $Shortcut.IconLocation = "{exe_path},0" + $Shortcut.Save() + """ + + result = subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + ) + + if not os.path.exists(shortcut_path): + raise Exception( + f"Failed to create shortcut: {result.stderr}" + ) + else: + raise Exception(f"Invalid desktop path: {desktop_path}") + + except Exception as e: + + print(f"Error creating desktop shortcut: {e}") + messagebox.showwarning( + "Warning", + "Could not create desktop shortcut. You may need to do this manually.", + ) + + self.update_progress(90, "Creating uninstaller...") + create_uninstaller(install_dir, extension_path) + + self.update_progress(95, "Starting application...") + subprocess.Popen([exe_path], cwd=install_dir) + + self.update_progress(100, "Installation complete!") + + messagebox.showinfo( + "Installation Complete", + "VLC Discord Rich Presence has been installed successfully!\n\n" + "The application is now running in the background.", + ) + + self.root.destroy() + + except Exception as e: + messagebox.showerror( + "Error", f"An error occurred during installation:\n\n{str(e)}" + ) + self.update_progress(0, "Installation failed.") + + def run(self): + + self.root.update_idletasks() + + required_height = self.root.winfo_reqheight() + if required_height > self.root.winfo_height(): + new_height = required_height + 20 + self.root.geometry(f"{self.root.winfo_width()}x{new_height}") + + self.root.mainloop() + + +if __name__ == "__main__": + app = InstallerGUI() + app.run() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/discord_handler.py b/src/discord_handler.py new file mode 100644 index 0000000..a57f6e1 --- /dev/null +++ b/src/discord_handler.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +VLC Discord Rich Presence Handler +A companion for the VLC Discord RP Lua script +""" + +import json +import logging +import os +import sys +import time + +import pypresence +from pypresence import ActivityType + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(), logging.FileHandler("vlc_discord_rp.log")], +) +logger = logging.getLogger("VLC-Discord-RP") + +CLIENT_ID = "1345358480671772683" + +LARGE_IMAGE = "logo" +PLAYING_IMAGE = "playing" +PAUSED_IMAGE = "paused" + + +def resource_path(relative_path): + """Get absolute path to resource, works for dev and PyInstaller""" + base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base_path, relative_path) + + +icon_path = resource_path(os.path.join("assets", "icon.ico")) + + +class VLCDiscordRP: + def __init__(self): + self.rpc = None + self.status_file = self._find_status_file() + self.last_status = {} + self.connected = False + self.last_file_mod_time = 0 + self.last_check_time = 0 + self.simulated_position = 0 + self.last_position_update = 0 + + def _find_status_file(self): + """Find the status file created by the VLC Lua script""" + + possible_paths = [ + os.path.join( + os.environ.get("APPDATA", ""), "vlc", "vlc_discord_status.json" + ), + os.path.expanduser( + "~/Library/Application Support/org.videolan.vlc/vlc_discord_status.json" + ), + os.path.expanduser("~/.local/share/vlc/vlc_discord_status.json"), + os.path.expanduser("~/.config/vlc/vlc_discord_status.json"), + "/tmp/vlc_discord_status.json", + ] + + for path in possible_paths: + if os.path.exists(path): + logger.info(f"Found status file at: {path}") + return path + + logger.warning("Status file not found. Using default location.") + return possible_paths[0] + + def connect(self): + """Connect to Discord""" + try: + self.rpc = pypresence.Presence(CLIENT_ID) + self.rpc.connect() + self.connected = True + logger.info("Connected to Discord") + return True + except Exception as e: + logger.error(f"Failed to connect to Discord: {e}") + self.connected = False + return False + + def read_status(self): + """Read the status file""" + try: + if not os.path.exists(self.status_file): + return None + + mod_time = os.path.getmtime(self.status_file) + if mod_time <= self.last_file_mod_time: + return None + + self.last_file_mod_time = mod_time + + with open(self.status_file, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + logger.error("Invalid JSON in status file") + return None + except Exception as e: + logger.error(f"Error reading status file: {e}") + return None + + def update_presence(self): + """Update Discord Rich Presence based on VLC status""" + status = self.read_status() + current_time = int(time.time()) + + if not status and self.last_status and self.last_status.get("active", False): + elapsed = current_time - self.last_check_time + self.last_check_time = current_time + + if self.last_status.get("status") == "playing": + + playback = self.last_status.get("playback", {}) + if playback.get("position") is not None: + self.simulated_position = playback.get("position", 0) + elapsed + + if self.simulated_position > playback.get("duration", 0): + self.simulated_position = playback.get("duration", 0) + + playback["position"] = self.simulated_position + playback["remaining"] = max( + 0, playback.get("duration", 0) - self.simulated_position + ) + self.last_status["playback"] = playback + + status = self.last_status + + if not status: + return False + + self.last_check_time = current_time + + if status != self.last_status: + self.last_status = status + + playback = status.get("playback", {}) + if playback.get("position") is not None: + self.simulated_position = playback.get("position", 0) + self.last_position_update = current_time + + if not status.get("active", False) or status.get("status") == "idle": + if self.connected: + try: + self.rpc.clear() + logger.info("Cleared presence (VLC inactive or idle)") + except Exception as e: + logger.error(f"Error clearing presence: {e}") + self.connected = False + return True + + media = status.get("media", {}) + playback = status.get("playback", {}) + current_status = status.get("status", "idle") + + details = media.get("title", "Unknown") + if len(details) > 128: + details = details[:125] + "..." + + is_likely_audio = bool(media.get("artist") or media.get("album")) + + activity_type = ( + ActivityType.LISTENING if is_likely_audio else ActivityType.WATCHING + ) + + if media.get("artist"): + state = f"by {media['artist']}" + if media.get("album"): + state += f" • {media['album']}" + elif media.get("album"): + state = f"from {media['album']}" + + else: + + if activity_type == ActivityType.WATCHING: + state = "Now playing" if current_status == "playing" else "Paused" + else: + state = "Now playing" if current_status == "playing" else "Paused" + if len(state) > 128: + state = state[:125] + "..." + + start_timestamp = None + end_timestamp = None + + if playback.get("position") is not None and playback.get("duration", 0) > 0: + position = playback.get("position", 0) + duration = playback.get("duration", 0) + + if current_status == "playing": + + start_timestamp = current_time - position + + if 0 < duration < 86400: # Max 24 hours + end_timestamp = start_timestamp + duration + else: + + pass + + try: + if not self.connected and not self.connect(): + return False + + small_image = PAUSED_IMAGE if current_status == "paused" else None + small_text = "Paused" if current_status == "paused" else "Playing" + + self.rpc.update( + details=details, + state=state, + large_image=LARGE_IMAGE, + large_text="VLC Media Player", + small_image=small_image, + small_text=small_text, + start=start_timestamp if current_status == "playing" else None, + end=end_timestamp if current_status == "playing" else None, + activity_type=activity_type, + ) + + activity_name = ( + "Listening to" + if activity_type == ActivityType.LISTENING + else "Watching" + ) + logger.info(f"Updated presence: {activity_name} {details} - {state}") + return True + except Exception as e: + logger.error(f"Error updating presence: {e}") + self.connected = False + return False + + def run(self): + """Main loop""" + logger.info("Starting VLC Discord Rich Presence Handler") + self.last_check_time = int(time.time()) + + while True: + try: + self.update_presence() + time.sleep(1) + except KeyboardInterrupt: + logger.info("Exiting due to user interrupt") + break + except Exception as e: + logger.error(f"Unexpected error: {e}") + time.sleep(5) + + if self.connected: + try: + self.rpc.clear() + self.rpc.close() + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + logger.info("Handler stopped") + + +if __name__ == "__main__": + handler = VLCDiscordRP() + handler.run() diff --git a/version_info.txt b/version_info.txt new file mode 100644 index 0000000..99b9172 --- /dev/null +++ b/version_info.txt @@ -0,0 +1,43 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx +VSVersionInfo( + ffi=FixedFileInfo( + # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) + # Set not needed items to zero 0. + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + # Contains a bitmask that specifies the valid bits 'flags'r + mask=0x3f, + # Contains a bitmask that specifies the Boolean attributes of the file. + flags=0x0, + # The operating system for which this file was designed. + # 0x4 - NT and there is no need to change it. + OS=0x40004, + # The general type of file. + # 0x1 - the file is an application. + fileType=0x1, + # The function of the file. + # 0x0 - the function is not defined for this fileType + subtype=0x0, + # Creation date and time stamp. + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'040904B0', + [StringStruct(u'CompanyName', u''), + StringStruct(u'FileDescription', u'VLC Discord Rich Presence'), + StringStruct(u'FileVersion', u'1.0.0'), + StringStruct(u'InternalName', u'VLC Discord Presence'), + StringStruct(u'LegalCopyright', u''), + StringStruct(u'OriginalFilename', u'VLC Discord Presence.exe'), + StringStruct(u'ProductName', u'VLC Discord Rich Presence'), + StringStruct(u'ProductVersion', u'1.0.0')]) + ]), + VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) + ] +) \ No newline at end of file diff --git a/vlc_discord_rp.spec b/vlc_discord_rp.spec new file mode 100644 index 0000000..33d778d --- /dev/null +++ b/vlc_discord_rp.spec @@ -0,0 +1,41 @@ +# -*- mode: python ; coding: utf-8 -*- + +a = Analysis( + ['src\\discord_handler.py'], + pathex=[], + binaries=[], + datas=[ + ('assets', 'assets'), # Include all assets + ('lua', 'lua'), # Include the Lua script for installation + ], + hiddenimports=['pypresence'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='VLC Discord Presence', # More professional name + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='assets/icon.ico', + version='version_info.txt', # Add version info +) \ No newline at end of file