From 530470ce5a65ed1bd65aeef61b06db209a4abdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 30 Dec 2024 12:46:28 -0600 Subject: [PATCH 1/3] Spotify source and improvements (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: Spotify Source, use Linamp as a Spotify Connect receiver * UI: New design for Sources menu * UI: Volume and balance sliders background color changes according to value * Refactor: Unify Bluetooth and Spotify sources code as generic audiosourcepython * Added third party licences information * Create librespot event handler script * Code cleanup * Updated changelog and debian packaging * Avoid initial delay if bluez is not available * Avoid polluting logs if no CDROM found --------- Co-authored-by: Rodrigo Méndez --- CMakeLists.txt | 8 +- LICENSE-3RD-PARTY.md | 61 ++ assets/menu-icon-x.png | Bin 0 -> 3321 bytes assets/source-icon-bluetooth.png | Bin 0 -> 4743 bytes assets/source-icon-cd.png | Bin 0 -> 5426 bytes assets/source-icon-file.png | Bin 0 -> 4499 bytes assets/source-icon-spotify.png | Bin 0 -> 4942 bytes debian/changelog | 9 + debian/rules | 5 +- python/linamp/__init__.py | 1 + python/linamp/baseplayer/baseplayer.py | 9 +- python/linamp/btplayer/btadapter.py | 56 +- python/linamp/btplayer/btplayer.py | 26 +- python/linamp/cdplayer.py | 5 +- python/linamp/spotifyplayer/__init__.py | 1 + .../spotifyplayer/librespot-event-handler.sh | 41 + python/linamp/spotifyplayer/spotifyadapter.py | 141 +++ python/linamp/spotifyplayer/spotifyplayer.py | 152 +++ .../audiosourcepython.cpp} | 141 +-- .../audiosourcepython.h} | 16 +- src/shared/linampslider.cpp | 89 ++ src/shared/linampslider.h | 26 + src/view-basewindow/mainwindow.cpp | 6 +- src/view-basewindow/mainwindow.h | 5 +- src/view-menu/mainmenuview.ui | 957 +++++++++++++++--- src/view-player/playerview.cpp | 28 + src/view-player/playerview.ui | 9 +- uiassets.qrc | 5 + 28 files changed, 1512 insertions(+), 285 deletions(-) create mode 100644 LICENSE-3RD-PARTY.md create mode 100644 assets/menu-icon-x.png create mode 100644 assets/source-icon-bluetooth.png create mode 100644 assets/source-icon-cd.png create mode 100644 assets/source-icon-file.png create mode 100644 assets/source-icon-spotify.png create mode 100644 python/linamp/spotifyplayer/__init__.py create mode 100755 python/linamp/spotifyplayer/librespot-event-handler.sh create mode 100644 python/linamp/spotifyplayer/spotifyadapter.py create mode 100644 python/linamp/spotifyplayer/spotifyplayer.py rename src/{audiosourcebluetooth/audiosourcebluetooth.cpp => audiosourcepython/audiosourcepython.cpp} (78%) rename src/{audiosourcebluetooth/audiosourcebluetooth.h => audiosourcepython/audiosourcepython.h} (81%) create mode 100644 src/shared/linampslider.cpp create mode 100644 src/shared/linampslider.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 33eee7e..9a5db14 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ qt_standard_project_setup() include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosource-base) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosource-coordinator) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosourcebluetooth) +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosourcepython) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosourcecd) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/audiosourcefile) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/shared) @@ -29,10 +29,10 @@ qt_add_executable(player WIN32 MACOSX_BUNDLE src/audiosource-base/audiosource.h src/audiosource-base/audiosourcewspectrumcapture.cpp src/audiosource-base/audiosourcewspectrumcapture.h - src/audiosourcebluetooth/audiosourcebluetooth.cpp - src/audiosourcebluetooth/audiosourcebluetooth.h src/audiosourcecd/audiosourcecd.cpp src/audiosourcecd/audiosourcecd.h + src/audiosourcepython/audiosourcepython.cpp + src/audiosourcepython/audiosourcepython.h src/audiosource-coordinator/audiosourcecoordinator.cpp src/audiosource-coordinator/audiosourcecoordinator.h src/audiosourcefile/audiosourcefile.cpp @@ -87,6 +87,8 @@ qt_add_executable(player WIN32 MACOSX_BUNDLE src/shared/fft.h src/shared/util.cpp src/shared/util.h + src/shared/linampslider.h + src/shared/linampslider.cpp src/main.cpp uiassets.qrc ) diff --git a/LICENSE-3RD-PARTY.md b/LICENSE-3RD-PARTY.md new file mode 100644 index 0000000..9c31707 --- /dev/null +++ b/LICENSE-3RD-PARTY.md @@ -0,0 +1,61 @@ +# THIRD PARTY LICENCES + +linamp-player includes the following third-party software/licensing: + + +## boxicons - https://boxicons.com/ + +The MIT License (MIT) + +Copyright (c) 2015-2021 Aniket Suvarna + +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. + + +## decorator-operations debounce - https://github.com/salesforce/decorator-operations/blob/master/decoratorOperations/debounce_functions/debounce.py + +Copyright (c) 2020, Salesforce.com, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +## RpiPythonCDPlayer - https://github.com/trybula/RpiPythonCDPlayer + +GPL v3: https://github.com/trybula/RpiPythonCDPlayer/blob/main/LICENSE + + +## QtMixer - https://github.com/Znurre/QtMixer + +GPL v2: https://github.com/Znurre/QtMixer/blob/master/LICENSE + + +## audacious fft.cc - https://github.com/audacious-media-player/audacious/blob/master/src/libaudcore/fft.cc + +Copyright 2011 John Lindgren + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions, and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions, and the following disclaimer in the documentation + provided with the distribution. + +This software is provided "as is" and without any warranty, express or +implied. In no event shall the authors be liable for any damages arising from +the use of this software. \ No newline at end of file diff --git a/assets/menu-icon-x.png b/assets/menu-icon-x.png new file mode 100644 index 0000000000000000000000000000000000000000..9cfcba90dfa004dd57e7691eced62e1a80d324ce GIT binary patch literal 3321 zcmeHK`#)59A0Oky(9AfbUB)zynG-6tZed(%j*eR>lgk#H>qd!O$|YH5&S+*>t3m71 zN+NWT+AP|~H9Qhlk|axuMYU)p!jsMO^~?SZ&+~fi59jiEpU?aA{k*=f@B8(6pH#O4 z4m4$kGKoZ@IXU8oNF+JR`bALyNXduLHSmx-eaOLRm!_vlj=eX zIYMgUF61%DP^NC^cWhXX2S){OhaL9`kUso2A1q2?_qc2zpP<0_rR%%XcmFTduvMu!-sW1t&FnMrj=BIv0(kiX!Fx=qO*Z8)J_C%Z`plk><2Yml~Ol#Rk(& z$#>k7x|mk>6FjwJ!ECl-Q?`C<0TLTI%u&o5l>3j}BNkWIW;E4^#Lii<*P+BCIUG>* zo9?yWV=Jt`j8Wps@UYm*O@*P`5u@=jqvpAt4>VWVFAz_Qwj zG*C+%kX)UDc;!6CCHz-u&OdzN3hHg{LT9)vf0msY< zZqq7gOl;Wm)t#mS{a}CYG#j$PGw(QDG|&_12Cvu1HHXDA;rlbLpCAscf28PLE9E*1tw6qyr4cd1=8nZglU zScp(#NjZL>Sd!h%bV!vdM2Ti!gNLW~*7<%9{V1x(F6?VH=m|?vCI<5f;cs;Mt=S5D zA17E*&1iXg&d@u=bY7XT$jeZS)I!V@B_{>Zgg#72<~X>EGIfCD1LId=Yv~#m$UlBv zVNi@Hi$?<%R)lkz=y>K3@@{kd~Ap;kIm1NJIbfqy*koeZ9i_B6sp# z!ckTN^6o;zLQ!LqE>>X@+E`qUUNWyU*vJLYtP3&Neq-gPK^7vJD%or)Lu~I4?b=g^ zc3hpcp5=ard~iE?__UXkY+tL6Wu!XB6gQH&6oO zMi&>&FCA5za7(t|e}xB^rh2`MSSL?Dxc-w3=0Wy3{p$-9qwg-i`t~Kn&yA=aStomF z33atL;hVZ+=$h+fy``KwHQaeLHVOBh)*is*~sC+dYaOg(edrCcZ{R*ixFB-6k+X< zDeqL^pXXc6gR?^{FEJeh=Y?$Pd&uG@P>Qw5)W*O*1BYCBCl|}ZjmVVBsn38?R;vJf zj~bEA79=@eR9FY-GzjdRhI?AEVM3`(L~k zF1Egx-!ex3MPPOOdlFW`4z2RPCgJuS9qNti1cTG4IV^n{aj1Vv;+NKuu`?No6B3L0 z5zB`;Q}M)WjYXF6J9P2Nr*QYsdg_Yx5+^)ajV}&KfLSNQfxDl365%C zzV^`HM^(%(91~u?DiLQMOjgHC)8<@Mrd=9L(~^gRD8<;BkVLnTnHvxEUE(0jBV(pp z+}K5@9GI8wk&9_R@545b?b?jT3ZHAO=L2Hqk=geFmAX( z#DYEqa$`TfSiFf!NoVC*f|#If;)j|w!$QQY&umZ&3qLHLRB#j-=tyap#%r34a5kio zr++FyyqdKp60%dnZE8wmb){A)E%Js@U4u&dqNX-ih^!jVcC4!3*qX}LxD{p#NBHl) z>lr-zw#+8qZ&%ktl|R3@Fk8o3!-@NuPu6GF^Xd6sYW+0=VKyN+2HbbOmq^x-d6u?oXb9i`~yJt?075yo(>ZzED#LFj&h z`I25#hIRzB_O8{XAZGV&f~@;0O=1B3-n` zg!Oy+%KK1a@WHGU5ESPo2b*$S=^yhxZkh7>7Q?*%0?)&;j`T;l`~>(fZI*yIF)2 z!CarNgtZ^2s!TLgJ#z+4oSlOBd2h1O#&3L*9 z!f_YySirdHoxIG3b$M{W*UE4QFmCf--*$&ufN@KAkt3f4#;vd-CXJUz!csD~@ema` zq9eQdfGP#JM?>QclnLM-&)yr@Mh*b((Y}l%v_wptpET3 literal 0 HcmV?d00001 diff --git a/assets/source-icon-bluetooth.png b/assets/source-icon-bluetooth.png new file mode 100644 index 0000000000000000000000000000000000000000..609d2c7cfdd14e826eacece107377cfd8978daa1 GIT binary patch literal 4743 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 literal 0 HcmV?d00001 diff --git a/assets/source-icon-spotify.png b/assets/source-icon-spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..7448669491ae048cbcec8e45593f2806d6cf6496 GIT binary patch literal 4942 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 Mon, 30 Dec 2024 12:04:00 -0600 + linamp (1.3.0) bookworm; urgency=medium * Feature: Bluetooth Source, use Linamp as a Bluetooth audio receiver diff --git a/debian/rules b/debian/rules index b791c4f..1ccd306 100755 --- a/debian/rules +++ b/debian/rules @@ -8,10 +8,7 @@ override_dh_auto_install: cp build/player debian/linamp/usr/bin/linamp-player mkdir -p debian/linamp/usr/lib/python3/dist-packages/linamp - cp python/linamp/__init__.py debian/linamp/usr/lib/python3/dist-packages/linamp/__init__.py - cp python/linamp/cdplayer.py debian/linamp/usr/lib/python3/dist-packages/linamp/cdplayer.py - cp -r python/linamp/baseplayer debian/linamp/usr/lib/python3/dist-packages/linamp/baseplayer - cp -r python/linamp/btplayer debian/linamp/usr/lib/python3/dist-packages/linamp/btplayer + cp -r python/linamp debian/linamp/usr/lib/python3/dist-packages/linamp mkdir -p debian/linamp/usr/share/applications/ cp linamp.desktop debian/linamp/usr/share/applications/ diff --git a/python/linamp/__init__.py b/python/linamp/__init__.py index 14ae616..73c72d9 100644 --- a/python/linamp/__init__.py +++ b/python/linamp/__init__.py @@ -1,2 +1,3 @@ from linamp.cdplayer import * from linamp.btplayer import * +from linamp.spotifyplayer import * diff --git a/python/linamp/baseplayer/baseplayer.py b/python/linamp/baseplayer/baseplayer.py index 1c1e5d0..cd5d9e8 100644 --- a/python/linamp/baseplayer/baseplayer.py +++ b/python/linamp/baseplayer/baseplayer.py @@ -79,7 +79,14 @@ def get_message(self) -> tuple[bool, str, int]: def clear_message(self) -> None: pass - # -------- Polling functions to be called by a timer -------- + # -------- Polling functions -------- + # Called by the UI before calling getter to update the view (currently once each second) + # Return True if you want to request focus to this source def poll_events(self) -> bool: return False + + # If your source requires asyncio or other event loop, run it here + # This will be run in a new thread when linamp starts + def run_loop(self): + pass \ No newline at end of file diff --git a/python/linamp/btplayer/btadapter.py b/python/linamp/btplayer/btadapter.py index d8f407f..f553ec0 100644 --- a/python/linamp/btplayer/btadapter.py +++ b/python/linamp/btplayer/btadapter.py @@ -4,8 +4,6 @@ from dbus_next.aio import MessageBus from dbus_next import BusType -loop = asyncio.get_event_loop() - SERVICE_NAME = 'org.bluez' DEVICE_IFACE = SERVICE_NAME + '.Device1' PLAYER_IFACE = SERVICE_NAME + '.MediaPlayer1' @@ -72,11 +70,6 @@ def __str__(self): return repr -def wait_for_loop() -> None: - """Antipattern: waits the asyncio running loop to end""" - while loop.is_running(): - pass - def is_empty_player_track(track: BTTrackInfo) -> bool: return track.duration <= 0 @@ -101,8 +94,17 @@ class BTPlayerAdapter(): repeat = 'off' shuffle = 'off' + loop = None + def __init__(self): - pass + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + def _wait_for_loop(self) -> None: + """Antipattern: waits the asyncio running loop to end""" + asyncio.set_event_loop(self.loop) + while self.loop.is_running(): + pass async def _get_dbus_object(self, path): introspection = await self.bus.introspect(SERVICE_NAME, path) @@ -157,6 +159,8 @@ def print_state(self): async def find_player(self): """Identify current player and device""" + if not self.manager: + return objects = await self.manager.call_get_managed_objects() player_path = None @@ -226,56 +230,56 @@ async def find_player(self): self.codec_configuration = None def setup_sync(self): - wait_for_loop() - loop.run_until_complete(self.setup()) + self._wait_for_loop() + self.loop.run_until_complete(self.setup()) def find_player_sync(self): - wait_for_loop() - loop.run_until_complete(self.find_player()) + self._wait_for_loop() + self.loop.run_until_complete(self.find_player()) def play(self): if not self.player_interface: return - wait_for_loop() - loop.run_until_complete(self.player_interface.call_play()) + self._wait_for_loop() + self.loop.run_until_complete(self.player_interface.call_play()) def pause(self): if not self.player_interface: return - wait_for_loop() - loop.run_until_complete(self.player_interface.call_pause()) + self._wait_for_loop() + self.loop.run_until_complete(self.player_interface.call_pause()) def stop(self): if not self.player_interface: return - wait_for_loop() - loop.run_until_complete(self.player_interface.call_stop()) + self._wait_for_loop() + self.loop.run_until_complete(self.player_interface.call_stop()) def next(self): if not self.player_interface: return - wait_for_loop() - loop.run_until_complete(self.player_interface.call_next()) + self._wait_for_loop() + self.loop.run_until_complete(self.player_interface.call_next()) def previous(self): if not self.player_interface: return - wait_for_loop() - loop.run_until_complete(self.player_interface.call_previous()) + self._wait_for_loop() + self.loop.run_until_complete(self.player_interface.call_previous()) def set_shuffle(self, enabled: bool) -> None: if not self.player_interface: return - wait_for_loop() + self._wait_for_loop() self.shuffle = 'alltracks' if enabled else 'off' - loop.run_until_complete(self.player_interface.set_shuffle(self.shuffle)) + self.loop.run_until_complete(self.player_interface.set_shuffle(self.shuffle)) def set_repeat(self, enabled: bool) -> None: if not self.player_interface: return - wait_for_loop() + self._wait_for_loop() self.repeat = 'alltracks' if enabled else 'off' - loop.run_until_complete(self.player_interface.set_repeat(self.repeat)) + self.loop.run_until_complete(self.player_interface.set_repeat(self.repeat)) def get_codec_str(self) -> str: if not self.codec: diff --git a/python/linamp/btplayer/btplayer.py b/python/linamp/btplayer/btplayer.py index 220426b..4ea9ef8 100644 --- a/python/linamp/btplayer/btplayer.py +++ b/python/linamp/btplayer/btplayer.py @@ -28,8 +28,6 @@ def __init__(self) -> None: self.clear_message() - self.player.setup_sync() - def _display_connection_info(self): if self.player.connected: self.message = f'CONNNECTED TO: {self.player.device_alias}' @@ -84,7 +82,7 @@ def prev(self) -> None: # Go to a specific time in a track while playing def seek(self, ms: int) -> None: - self.message = "NOT SUPPORTED" + self.message = 'NOT SUPPORTED' self.show_message = True self.message_timeout = 3000 @@ -95,7 +93,7 @@ def set_repeat(self, enabled: bool) -> None: self.player.set_repeat(enabled) def eject(self) -> None: - self.message = "NOT SUPPORTED" + self.message = 'NOT SUPPORTED' self.show_message = True self.message_timeout = 3000 @@ -105,26 +103,26 @@ def get_postition(self) -> int: return self.player.position def get_shuffle(self) -> bool: - return self.player.shuffle != "off" + return self.player.shuffle != 'off' def get_repeat(self) -> bool: - return self.player.repeat != "off" + return self.player.repeat != 'off' # Returns the str representation of PlayerStatus enum def get_status(self) -> str: status = PlayerStatus.Idle btstatus = self.player.status - if btstatus == "playing": + if btstatus == 'playing': status = PlayerStatus.Playing - if btstatus == "stopped": + if btstatus == 'stopped': status = PlayerStatus.Stopped - if btstatus == "paused": + if btstatus == 'paused': status = PlayerStatus.Paused - if btstatus == "error": + if btstatus == 'error': status = PlayerStatus.Error - if btstatus == "forward-seek": + if btstatus == 'forward-seek': status = PlayerStatus.Loading - if btstatus == "reverse-seek": + if btstatus == 'reverse-seek': status = PlayerStatus.Loading return status.value @@ -148,3 +146,7 @@ def poll_events(self) -> bool: # Should tell UI to refresh if we are connected and were not connected before return self.player.connected and not was_connected + + # This will be run in a new thread when linamp starts + def run_loop(self): + self.player.setup_sync() \ No newline at end of file diff --git a/python/linamp/cdplayer.py b/python/linamp/cdplayer.py index 02785f4..cac49e7 100644 --- a/python/linamp/cdplayer.py +++ b/python/linamp/cdplayer.py @@ -401,7 +401,10 @@ def detect_disc_insertion(self): return False d.close() except Exception as e: - print(f"Problem finding a CD-ROM. {e}") + if self._detect_disc_insertion_is_first_call: + # Only print this once to avoid polluting the logs + print(f"Problem finding a CD-ROM. {e}") + self._detect_disc_insertion_is_first_call = False return False diff --git a/python/linamp/spotifyplayer/__init__.py b/python/linamp/spotifyplayer/__init__.py new file mode 100644 index 0000000..c0f6614 --- /dev/null +++ b/python/linamp/spotifyplayer/__init__.py @@ -0,0 +1 @@ +from linamp.spotifyplayer.spotifyplayer import * diff --git a/python/linamp/spotifyplayer/librespot-event-handler.sh b/python/linamp/spotifyplayer/librespot-event-handler.sh new file mode 100755 index 0000000..be53cb5 --- /dev/null +++ b/python/linamp/spotifyplayer/librespot-event-handler.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +if [[ $PLAYER_EVENT == 'session_connected' || $PLAYER_EVENT == 'session_disconnected' ]]; then + dbus-send --type=method_call --dest=org.linamp.Librespot /org/linamp/librespot org.linamp.LibrespotInterface.send_event dict:string:string:'event',"$PLAYER_EVENT",'user_name',"$USER_NAME",'connection_id',"$CONNECTION_ID" +fi + +if [[ $PLAYER_EVENT == 'shuffle_changed' ]]; then + dbus-send --type=method_call --dest=org.linamp.Librespot /org/linamp/librespot org.linamp.LibrespotInterface.send_event dict:string:string:'event',"$PLAYER_EVENT",'shuffle',"$SHUFFLE" +fi + +if [[ $PLAYER_EVENT == 'repeat_changed' ]]; then + dbus-send --type=method_call --dest=org.linamp.Librespot /org/linamp/librespot org.linamp.LibrespotInterface.send_event dict:string:string:'event',"$PLAYER_EVENT",'repeat',"$REPEAT" +fi + +if [[ $PLAYER_EVENT == 'track_changed' ]]; then + dbus-send --type=method_call --dest=org.linamp.Librespot /org/linamp/librespot org.linamp.LibrespotInterface.send_event dict:string:string:'event',"$PLAYER_EVENT",\ +'item_type',"${ITEM_TYPE:-''}",\ +'track_id',"${TRACK_ID:-''}",\ +'uri',"${URI:-''}",\ +'name',"${NAME:-''}",\ +'duration_ms',"${DURATION_MS:-''}",\ +'is_explicit',"${IS_EXPLICIT:-''}",\ +'language',"${LANGUAGE:-''}",\ +'covers',"${COVERS:-''}",\ +'number',"${NUMBER:-''}",\ +'disc_number',"${DISC_NUMBER:-''}",\ +'popularity',"${POPULARITY:-''}",\ +'album',"${ALBUM:-''}",\ +'artists',"${ARTISTS:-''}",\ +'album_artists',"${ALBUM_ARTISTS:-''}",\ +'show_name',"${SHOW_NAME:-''}",\ +'publish_time',"${PUBLISH_TIME:-''}",\ +'description',"${DESCRIPTION:-''}" +fi + +if [[ $PLAYER_EVENT == 'playing' || $PLAYER_EVENT == 'paused' || $PLAYER_EVENT == 'seeked' || $PLAYER_EVENT == 'position_correction' ]]; then + dbus-send --type=method_call --dest=org.linamp.Librespot /org/linamp/librespot org.linamp.LibrespotInterface.send_event dict:string:string:'event',"$PLAYER_EVENT",'track_id',"$TRACK_ID",'position_ms',"$POSITION_MS" +fi \ No newline at end of file diff --git a/python/linamp/spotifyplayer/spotifyadapter.py b/python/linamp/spotifyplayer/spotifyadapter.py new file mode 100644 index 0000000..1cf6d2f --- /dev/null +++ b/python/linamp/spotifyplayer/spotifyadapter.py @@ -0,0 +1,141 @@ +import asyncio +import time + +from dbus_next.aio import MessageBus +from dbus_next.service import ServiceInterface, method + +DBUS_INTERFACE = 'org.linamp.LibrespotInterface' + +class SpotifyTrackInfo(): + def __init__(self, title: str = '', track_number: int = 0, number_of_tracks: int = 0, duration: int = 0, album: str = '', artist: str = ''): + self.title = title + self.track_number = track_number + self.number_of_tracks = number_of_tracks + self.duration = duration + self.album = album + self.artist = artist + + def __str__(self): + repr = '\n' + repr = repr + f' Title: {self.title}\n' + repr = repr + f' Album: {self.album}\n' + repr = repr + f' Artist: {self.artist}\n' + + seconds_total = self.duration/1000 + minutes = int(seconds_total/60) + seconds = int(seconds_total - (minutes * 60)) + + repr = repr + f' Duration: {str(minutes).zfill(2)}:{str(seconds).zfill(2)} ({self.duration})\n' + repr = repr + f' Track Number: {self.track_number}\n' + repr = repr + f' Number of Tracks: {self.number_of_tracks}\n' + + return repr + +def is_empty_player_track(track: SpotifyTrackInfo) -> bool: + return track.duration <= 0 + + +# Posible values for status: +# - stopped +# - playing +# - paused + +class SpotifyPlayerAdapter(ServiceInterface): + bus = None + + connected = False + status = 'stopped' + track = SpotifyTrackInfo() + position = 0 + last_updated_position = None # time + repeat = 'off' + shuffle = 'off' + + def __init__(self): + super().__init__(DBUS_INTERFACE) + + @method() + def send_event(self, data: 'a{ss}'): + event = data.get('event') + if event == 'session_connected': + self.connected = True + if event == 'session_disconnected': + self.connected = False + self.status = 'stopped' + self.track = SpotifyTrackInfo() + self._set_position(0) + self.repeat = 'off' + self.shuffle = 'off' + if event == 'shuffle_changed': + shuffle_str = data.get('shuffle', '') + self.shuffle = 'on' if shuffle_str == 'true' else 'off' + if event == 'repeat_changed': + repeat_str = data.get('repeat', '') + self.repeat = 'on' if repeat_str == 'true' else 'off' + if event == 'playing' or event == 'paused': + self.connected = True + self.status = event + self._set_position(int(data.get('position_ms', '0'))) + if event == 'seeked' or event == 'position_correction': + self._set_position(int(data.get('position_ms', '0'))) + if event == 'track_changed': + item_type = data.get('item_type') + + title = data.get('name', '') + number_of_tracks = 0 + duration = int(data.get('duration_ms', '0')) + + track_number = int(data.get('number', '0')) + album = data.get('album', '') + artist = data.get('artists', '') + + if item_type == 'Episode': + # Special case for podcasts + track_number = 0 + album = '' + artist = data.get('show_name', '') + + self.track = SpotifyTrackInfo(title, track_number, number_of_tracks, duration, album, artist) + + def _set_position(self, pos: int): + self.position = pos + self.last_updated_position = time.time_ns() + + def get_postition(self) -> int: + """Updates position when playing if it hasn't been updated by an event""" + now = time.time_ns() + then = self.last_updated_position + if self.status == 'playing' and then is not None: + diff_ms = (now - then)/1000000 + self._set_position(int(self.position + diff_ms)) + + return self.position + + async def setup(self): + """Initialize DBus""" + self.bus = await MessageBus().connect() + self.bus.export('/org/linamp/librespot', self) + + async def loop(self): + await self.setup() + await self.bus.request_name('org.linamp.Librespot') + await self.bus.wait_for_disconnect() + + def print_state(self): + print(f'Connected: {self.connected}') + print(f'Status: {self.status}') + + seconds_total = self.position/1000 + minutes = int(seconds_total/60) + seconds = int(seconds_total - (minutes * 60)) + + print(f'Position: {str(minutes).zfill(2)}:{str(seconds).zfill(2)} ({self.position})') + print(f'Repeat: {self.repeat}') + print(f'Shuffle: {self.shuffle}') + print(f'Track: {self.track}') + + # Runs the asyncio event loop, should be called from a new thread + def run_loop(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.loop()) diff --git a/python/linamp/spotifyplayer/spotifyplayer.py b/python/linamp/spotifyplayer/spotifyplayer.py new file mode 100644 index 0000000..d1e58a6 --- /dev/null +++ b/python/linamp/spotifyplayer/spotifyplayer.py @@ -0,0 +1,152 @@ +from linamp.baseplayer import BasePlayer, PlayerStatus +from linamp.spotifyplayer.spotifyadapter import SpotifyPlayerAdapter, is_empty_player_track + +EMPTY_TRACK_INFO = ( + 0, + '', + '', + '', + 0, + '', + 0, + 44100 +) + +class SpotifyPlayer(BasePlayer): + + message: str + show_message: bool + message_timeout: int + + player: SpotifyPlayerAdapter + track_info: tuple[int, str, str, str, int, str, int, int] + + was_connected = False + + def __init__(self) -> None: + self.player = SpotifyPlayerAdapter() + # tuple with format (tracknumber: int, artist, album, title, duration: int, codec: str, bitrate_bps: int, samplerate_hz: int) + self.track_info = EMPTY_TRACK_INFO + + self.clear_message() + + def _display_connection_info(self): + if self.player.connected: + self.message = 'CONNNECTED' + self.show_message = True + self.message_timeout = 5000 + else: + self.message = 'DISCONNECTED' + self.show_message = True + self.message_timeout = 5000 + + def _not_supported(self): + self.message = 'NOT SUPPORTED' + self.show_message = True + self.message_timeout = 3000 + + # -------- Control Functions -------- + + def load(self) -> None: + if self.player.connected: + track = self.player.track + if not track or is_empty_player_track(track): + self._display_connection_info() + return + self.track_info = ( + track.track_number, + track.artist, + track.album, + track.title, + track.duration, + '', + 320000, + 44100 + ) + else: + self.track_info = EMPTY_TRACK_INFO + self._display_connection_info() + + def unload(self) -> None: + self.track_info = EMPTY_TRACK_INFO + self.clear_message() + + def play(self) -> None: + self._not_supported() + + def stop(self) -> None: + self._not_supported() + + def pause(self) -> None: + self._not_supported() + + def next(self) -> None: + self._not_supported() + + def prev(self) -> None: + self._not_supported() + + # Go to a specific time in a track while playing + def seek(self, ms: int) -> None: + self._not_supported() + + def set_shuffle(self, enabled: bool) -> None: + self._not_supported() + + def set_repeat(self, enabled: bool) -> None: + self._not_supported() + + def eject(self) -> None: + self._not_supported() + + # -------- Status Functions -------- + + def get_postition(self) -> int: + return self.player.get_postition() + + def get_shuffle(self) -> bool: + return self.player.shuffle != 'off' + + def get_repeat(self) -> bool: + return self.player.repeat != 'off' + + # Returns the str representation of PlayerStatus enum + def get_status(self) -> str: + status = PlayerStatus.Idle + spotstatus = self.player.status + if spotstatus == 'playing': + status = PlayerStatus.Playing + if spotstatus == 'stopped': + status = PlayerStatus.Stopped + if spotstatus == 'paused': + status = PlayerStatus.Paused + if spotstatus == 'error': + status = PlayerStatus.Error + return status.value + + def get_track_info(self) -> tuple[int, str, str, str, int, str, int, int]: + return self.track_info + + # Return any message you want to show to the user. tuple with format: (show_message: bool, message: str, message_timeout_ms: int) + def get_message(self) -> tuple[bool, str, int]: + return (self.show_message, self.message, self.message_timeout) + + def clear_message(self) -> None: + self.show_message = False + self.message = '' + self.message_timeout = 0 + + # -------- Events to be called by a timer -------- + + def poll_events(self) -> bool: + self.load() + + should_request_focus = self.player.connected and not self.was_connected + self.was_connected = self.player.connected + + # Should tell UI to refresh if we are connected and were not connected before + return should_request_focus + + # Runs the asyncio event loop, should be called from a new thread + def run_loop(self): + self.player.run_loop() \ No newline at end of file diff --git a/src/audiosourcebluetooth/audiosourcebluetooth.cpp b/src/audiosourcepython/audiosourcepython.cpp similarity index 78% rename from src/audiosourcebluetooth/audiosourcebluetooth.cpp rename to src/audiosourcepython/audiosourcepython.cpp index 42a5442..37494ca 100644 --- a/src/audiosourcebluetooth/audiosourcebluetooth.cpp +++ b/src/audiosourcepython/audiosourcepython.cpp @@ -1,30 +1,29 @@ -#include "audiosourcebluetooth.h" +#include "audiosourcepython.h" //#define DEBUG_ASPY #define ASPY_PROGRESS_INTERPOLATION_TIME 100 -AudioSourceBluetooth::AudioSourceBluetooth(QObject *parent) +AudioSourcePython::AudioSourcePython(QString module, QString className, QObject *parent) : AudioSourceWSpectrumCapture{parent} { auto state = PyGILState_Ensure(); - // Import 'linamp' python module, see python folder in the root of this repo - PyObject *pModuleName = PyUnicode_DecodeFSDefault("linamp"); - //PyObject *pModuleName = PyUnicode_DecodeFSDefault("linamp-mock"); + // Import specified python module + PyObject *pModuleName = PyUnicode_DecodeFSDefault(module.toStdString().c_str()); playerModule = PyImport_Import(pModuleName); Py_DECREF(pModuleName); if(playerModule == nullptr) { - qDebug() << "Couldn't load python module"; + qDebug() << "AudioSourcePython: Couldn't load python module"; PyGILState_Release(state); return; } - PyObject *PlayerClass = PyObject_GetAttrString(playerModule, "BTPlayer"); + PyObject *PlayerClass = PyObject_GetAttrString(playerModule, className.toStdString().c_str()); if(!PlayerClass || !PyCallable_Check(PlayerClass)) { - qDebug() << "Error getting Player Class"; + qDebug() << "AudioSourcePython: Error getting Player Class"; PyGILState_Release(state); return; } @@ -37,67 +36,70 @@ AudioSourceBluetooth::AudioSourceBluetooth(QObject *parent) // Timer to poll for events and load pollEventsTimer = new QTimer(this); pollEventsTimer->setInterval(1000); - connect(pollEventsTimer, &QTimer::timeout, this, &AudioSourceBluetooth::pollEvents); + connect(pollEventsTimer, &QTimer::timeout, this, &AudioSourcePython::pollEvents); pollEventsTimer->start(); // Watch for async events poll results - connect(&pollResultWatcher, &QFutureWatcher::finished, this, &AudioSourceBluetooth::handlePollResult); + connect(&pollResultWatcher, &QFutureWatcher::finished, this, &AudioSourcePython::handlePollResult); // Handle load end - connect(&loadWatcher, &QFutureWatcher::finished, this, &AudioSourceBluetooth::handleLoadEnd); + connect(&loadWatcher, &QFutureWatcher::finished, this, &AudioSourcePython::handleLoadEnd); // Handle finish ejecting - connect(&ejectWatcher, &QFutureWatcher::finished, this, &AudioSourceBluetooth::handleEjectEnd); + connect(&ejectWatcher, &QFutureWatcher::finished, this, &AudioSourcePython::handleEjectEnd); // Track progress with timer progressRefreshTimer = new QTimer(this); progressRefreshTimer->setInterval(1000); - connect(progressRefreshTimer, &QTimer::timeout, this, &AudioSourceBluetooth::refreshProgress); + connect(progressRefreshTimer, &QTimer::timeout, this, &AudioSourcePython::refreshProgress); progressInterpolateTimer = new QTimer(this); progressInterpolateTimer->setInterval(ASPY_PROGRESS_INTERPOLATION_TIME); - connect(progressInterpolateTimer, &QTimer::timeout, this, &AudioSourceBluetooth::interpolateProgress); + connect(progressInterpolateTimer, &QTimer::timeout, this, &AudioSourcePython::interpolateProgress); PyGILState_Release(state); + + QFuture pyLoopFuture = QtConcurrent::run(&AudioSourcePython::runPythonLoop, this); + pyLoopWatcher.setFuture(pyLoopFuture); } -AudioSourceBluetooth::~AudioSourceBluetooth() +AudioSourcePython::~AudioSourcePython() { } -void AudioSourceBluetooth::pollEvents() +void AudioSourcePython::pollEvents() { if(pollResultWatcher.isRunning() || loadWatcher.isRunning()) { #ifdef DEBUG_ASPY - qDebug() << ">>>>>>>>>>>>>>>POLL Avoided"; + qDebug() << "AudioSourcePython.pollEvents: Poll Avoided"; #endif return; } if(pollInProgress) return; pollInProgress = true; #ifdef DEBUG_ASPY - qDebug() << "pollEvents: polling"; + qDebug() << "AudioSourcePython.pollEvents: polling"; #endif - QFuture status = QtConcurrent::run(&AudioSourceBluetooth::doPollEvents, this); + QFuture status = QtConcurrent::run(&AudioSourcePython::doPollEvents, this); pollResultWatcher.setFuture(status); } -void AudioSourceBluetooth::handlePollResult() +void AudioSourcePython::handlePollResult() { #ifdef DEBUG_ASPY - qDebug() << ">>>>POLL RESULT"; + qDebug() << "AudioSourcePython.handlePollResult: Got Poll Result"; #endif bool changeDetected = pollResultWatcher.result(); if(changeDetected) { if(loadWatcher.isRunning()) { #ifdef DEBUG_ASPY - qDebug() << ">>>>>>>>>>>>>>>LOAD Avoided"; + qDebug() << "AudioSourcePython.handlePollResult: Load Avoided"; #endif return; } emit this->requestActivation(); // Request audiosource coordinator to select us - QFuture status = QtConcurrent::run(&AudioSourceBluetooth::doLoad, this); + QFuture status = QtConcurrent::run(&AudioSourcePython::doLoad, this); loadWatcher.setFuture(status); } else { refreshStatus(); @@ -108,7 +110,7 @@ void AudioSourceBluetooth::handlePollResult() this->refreshMessage(); } -bool AudioSourceBluetooth::doPollEvents() +bool AudioSourcePython::doPollEvents() { bool changeDetected = false; if(player == nullptr) return changeDetected; @@ -119,11 +121,11 @@ bool AudioSourceBluetooth::doPollEvents() if(pyChangeDetected != nullptr && PyBool_Check(pyChangeDetected)) { changeDetected = PyObject_IsTrue(pyChangeDetected); #ifdef DEBUG_ASPY - qDebug() << ">>>Change detected?:" << changeDetected; + qDebug() << "AudioSourcePython.doPollEvents: Change detected?: " << changeDetected; #endif } else { #ifdef DEBUG_ASPY - qDebug() << ">>>>pollEvents: Not a bool"; + qDebug() << "AudioSourcePython.doPollEvents: pyChangeDetected not a bool"; #endif } if(pyChangeDetected) Py_DECREF(pyChangeDetected); @@ -131,28 +133,28 @@ bool AudioSourceBluetooth::doPollEvents() return changeDetected; } -void AudioSourceBluetooth::doLoad() +void AudioSourcePython::doLoad() { auto state = PyGILState_Ensure(); PyObject_CallMethod(player, "load", NULL); PyGILState_Release(state); } -void AudioSourceBluetooth::handleLoadEnd() +void AudioSourcePython::handleLoadEnd() { emit this->messageClear(); refreshStatus(); pollInProgress = false; } -void AudioSourceBluetooth::doEject() +void AudioSourcePython::doEject() { auto state = PyGILState_Ensure(); PyObject_CallMethod(player, "eject", NULL); PyGILState_Release(state); } -void AudioSourceBluetooth::handleEjectEnd() +void AudioSourcePython::handleEjectEnd() { emit this->messageClear(); // Empty metadata @@ -162,7 +164,7 @@ void AudioSourceBluetooth::handleEjectEnd() } -void AudioSourceBluetooth::activate() +void AudioSourcePython::activate() { emit playbackStateChanged(MediaPlayer::StoppedState); emit positionChanged(0); @@ -180,7 +182,7 @@ void AudioSourceBluetooth::activate() isActive = true; } -void AudioSourceBluetooth::deactivate() +void AudioSourcePython::deactivate() { isActive = false; @@ -195,12 +197,12 @@ void AudioSourceBluetooth::deactivate() } -void AudioSourceBluetooth::handlePl() +void AudioSourcePython::handlePl() { emit plEnabledChanged(false); } -void AudioSourceBluetooth::handlePrevious() +void AudioSourcePython::handlePrevious() { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -212,7 +214,7 @@ void AudioSourceBluetooth::handlePrevious() refreshStatus(); } -void AudioSourceBluetooth::handlePlay() +void AudioSourcePython::handlePlay() { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -224,7 +226,7 @@ void AudioSourceBluetooth::handlePlay() refreshStatus(); } -void AudioSourceBluetooth::handlePause() +void AudioSourcePython::handlePause() { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -236,7 +238,7 @@ void AudioSourceBluetooth::handlePause() refreshStatus(); } -void AudioSourceBluetooth::handleStop() +void AudioSourcePython::handleStop() { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -248,7 +250,7 @@ void AudioSourceBluetooth::handleStop() refreshStatus(); } -void AudioSourceBluetooth::handleNext() +void AudioSourcePython::handleNext() { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -260,23 +262,23 @@ void AudioSourceBluetooth::handleNext() refreshStatus(); } -void AudioSourceBluetooth::handleOpen() +void AudioSourcePython::handleOpen() { if(player == nullptr) return; #ifdef DEBUG_ASPY - qDebug() << "<<<<>>>>>>>>>>>>>>EJECT Avoided"; + qDebug() << "AudioSourcePython.handleOpen: EJECT Avoided"; #endif return; } - QFuture status = QtConcurrent::run(&AudioSourceBluetooth::doEject, this); + QFuture status = QtConcurrent::run(&AudioSourcePython::doEject, this); ejectWatcher.setFuture(status); } -void AudioSourceBluetooth::handleShuffle() +void AudioSourcePython::handleShuffle() { if(player == nullptr) return; @@ -289,7 +291,7 @@ void AudioSourceBluetooth::handleShuffle() refreshStatus(false); } -void AudioSourceBluetooth::handleRepeat() +void AudioSourcePython::handleRepeat() { if(player == nullptr) return; @@ -302,7 +304,7 @@ void AudioSourceBluetooth::handleRepeat() refreshStatus(false); } -void AudioSourceBluetooth::handleSeek(int mseconds) +void AudioSourcePython::handleSeek(int mseconds) { if(player == nullptr) return; @@ -313,7 +315,7 @@ void AudioSourceBluetooth::handleSeek(int mseconds) refreshStatus(false); } -void AudioSourceBluetooth::refreshStatus(bool shouldRefreshTrackInfo) +void AudioSourcePython::refreshStatus(bool shouldRefreshTrackInfo) { if(player == nullptr) return; auto state = PyGILState_Ensure(); @@ -345,7 +347,7 @@ void AudioSourceBluetooth::refreshStatus(bool shouldRefreshTrackInfo) PyGILState_Release(state); #ifdef DEBUG_ASPY - qDebug() << ">>>Status" << status; + qDebug() << "AudioSourcePython.refreshStatus: Status: " << status; #endif if(status == "idle") { @@ -420,23 +422,23 @@ void AudioSourceBluetooth::refreshStatus(bool shouldRefreshTrackInfo) } } -void AudioSourceBluetooth::refreshTrackInfo(bool force) +void AudioSourcePython::refreshTrackInfo(bool force) { #ifdef DEBUG_ASPY - qDebug() << ">>>>>>>>>Refresh track info"; + qDebug() << "AudioSourcePython.refreshTrackInfo: Refresh track info"; #endif if(player == nullptr) return; auto state = PyGILState_Ensure(); PyObject *pyTrackInfo = PyObject_CallMethod(player, "get_track_info", NULL); if(pyTrackInfo == nullptr) { #ifdef DEBUG_ASPY - qDebug() << ">>> Couldn't get track info"; + qDebug() << "AudioSourcePython.refreshTrackInfo: Couldn't get track info"; #endif PyErr_Print(); PyGILState_Release(state); return; } - // format (tracknumber: int, artist, album, title, duration: int, is_data_track: bool) + // format (tracknumber: int, artist, album, title, duration: int, codec: str, bitrate: int, samplerate: int) PyObject *pyTrackNumber = PyTuple_GetItem(pyTrackInfo, 0); PyObject *pyArtist = PyTuple_GetItem(pyTrackInfo, 1); PyObject *pyAlbum = PyTuple_GetItem(pyTrackInfo, 2); @@ -482,7 +484,7 @@ void AudioSourceBluetooth::refreshTrackInfo(bool force) emit this->metadataChanged(metadata); #ifdef DEBUG_ASPY - qDebug() << ">>>>>>>>METADATA changed"; + qDebug() << "AudioSourcePython.refreshTrackInfo: METADATA changed"; #endif Py_DECREF(pyTrackInfo); @@ -491,7 +493,7 @@ void AudioSourceBluetooth::refreshTrackInfo(bool force) } -void AudioSourceBluetooth::refreshProgress() +void AudioSourcePython::refreshProgress() { if(player == nullptr) return; @@ -502,7 +504,7 @@ void AudioSourceBluetooth::refreshProgress() PyObject *pyPosition = PyObject_CallMethod(player, "get_postition", NULL); if(pyPosition == nullptr) { #ifdef DEBUG_ASPY - qDebug() << ">>> Couldn't get track position"; + qDebug() << "AudioSourcePython.refreshProgress: Couldn't get track position"; #endif PyErr_Print(); PyGILState_Release(state); @@ -513,7 +515,7 @@ void AudioSourceBluetooth::refreshProgress() int diff = (int)this->currentProgress - (int)position; #ifdef DEBUG_ASPY - qDebug() << ">>>>Time diff" << diff; + qDebug() << "AudioSourcePython.refreshProgress: Time diff: " << diff; #endif // Avoid small jumps caused by the python method latency @@ -527,7 +529,7 @@ void AudioSourceBluetooth::refreshProgress() PyGILState_Release(state); } -void AudioSourceBluetooth::interpolateProgress() +void AudioSourcePython::interpolateProgress() { if(!progressInterpolateElapsedTimer.isValid()) { // Handle first time @@ -545,19 +547,15 @@ void AudioSourceBluetooth::interpolateProgress() emit this->positionChanged(this->currentProgress); } -void AudioSourceBluetooth::refreshMessage() +void AudioSourcePython::refreshMessage() { - if(player == nullptr){ - qDebug() << "<<>> Couldn't get track message data"; + qDebug() << "AudioSourcePython.refreshMessage: Couldn't get track message data"; #endif PyErr_Print(); PyGILState_Release(state); @@ -581,3 +579,22 @@ void AudioSourceBluetooth::refreshMessage() Py_DECREF(pyMessageData); PyGILState_Release(state); } + +void AudioSourcePython::runPythonLoop() +{ + if(player == nullptr) return; + auto state = PyGILState_Ensure(); + + PyObject *pyRunLoop = PyObject_CallMethod(player, "run_loop", NULL); + if(pyRunLoop == nullptr) { + #ifdef DEBUG_ASPY + qDebug() << "AudioSourcePython.runPythonLoop: Couldn't run Python event loop"; + #endif + PyErr_Print(); + PyGILState_Release(state); + return; + } + + Py_DECREF(pyRunLoop); + PyGILState_Release(state); +} diff --git a/src/audiosourcebluetooth/audiosourcebluetooth.h b/src/audiosourcepython/audiosourcepython.h similarity index 81% rename from src/audiosourcebluetooth/audiosourcebluetooth.h rename to src/audiosourcepython/audiosourcepython.h index 69008cb..665f790 100644 --- a/src/audiosourcebluetooth/audiosourcebluetooth.h +++ b/src/audiosourcepython/audiosourcepython.h @@ -1,5 +1,5 @@ -#ifndef AUDIOSOURCEBLUETOOTH_H -#define AUDIOSOURCEBLUETOOTH_H +#ifndef AUDIOSOURCEPYTHON_H +#define AUDIOSOURCEPYTHON_H #define PY_SSIZE_T_CLEAN #undef slots @@ -12,12 +12,12 @@ #include "audiosourcewspectrumcapture.h" -class AudioSourceBluetooth : public AudioSourceWSpectrumCapture +class AudioSourcePython : public AudioSourceWSpectrumCapture { Q_OBJECT public: - explicit AudioSourceBluetooth(QObject *parent = nullptr); - ~AudioSourceBluetooth(); + explicit AudioSourcePython(QString module, QString className, QObject *parent = nullptr); + ~AudioSourcePython(); public slots: void activate(); @@ -46,6 +46,10 @@ public slots: void refreshProgress(); void interpolateProgress(); + // Python event loop thread + void runPythonLoop(); + QFutureWatcher pyLoopWatcher; + // Poll events thread QTimer *pollEventsTimer = nullptr; bool pollInProgress = false; @@ -75,4 +79,4 @@ public slots: QMediaMetaData currentMetadata; }; -#endif // AUDIOSOURCEBLUETOOTH_H +#endif // AUDIOSOURCEPYTHON_H diff --git a/src/shared/linampslider.cpp b/src/shared/linampslider.cpp new file mode 100644 index 0000000..746844d --- /dev/null +++ b/src/shared/linampslider.cpp @@ -0,0 +1,89 @@ +#include "linampslider.h" +#include +#include + +LinampSlider::LinampSlider(QWidget* parent) : + QSlider(parent), + gradient(QSize(100,100), QImage::Format_RGB32) +{ +} + +void LinampSlider::setGradient(QColor from, QColor to, Qt::Orientation orientation) +{ + setOrientation(orientation); + // create linear gradient + QLinearGradient linearGrad(QPointF(0, 0), (orientation==Qt::Horizontal) ? QPointF(100, 0) : QPointF(0, 100)); + linearGrad.setColorAt(0, from); + linearGrad.setColorAt(1, to); + + // paint gradient in a QImage: + QPainter p(&gradient); + p.fillRect(gradient.rect(), linearGrad); + + connect(this, SIGNAL(valueChanged(int)), this, SLOT(changeColor(int))); + + // initialize + changeColor(value()); +} + +void LinampSlider::setGradient(QList steps, Qt::Orientation orientation) +{ + setOrientation(orientation); + // create linear gradient + QLinearGradient linearGrad(QPointF(0, 0), (orientation==Qt::Horizontal) ? QPointF(100, 0) : QPointF(0, 100)); + for(int i = 0; i < steps.count(); i++) { + linearGrad.setColorAt(float(i)/float(steps.count() - 1), steps[i]); + } + + // paint gradient in a QImage: + QPainter p(&gradient); + p.fillRect(gradient.rect(), linearGrad); + + connect(this, SIGNAL(valueChanged(int)), this, SLOT(changeColor(int))); + + // initialize + changeColor(value()); +} + +void LinampSlider::changeColor(int pos) +{ + QColor color; + + if (orientation() == Qt::Horizontal) + { + // retrieve color index based on cursor position + int posIndex = gradient.size().width() * (pos - minimum()) / (maximum() - minimum()); + posIndex = std::min(posIndex, gradient.width() - 1); + + // pickup appropriate color + color = gradient.pixel(posIndex, gradient.size().height()/2); + } + else + { + // retrieve color index based on cursor position + int posIndex = gradient.size().height() * (pos - minimum()) / (maximum() - minimum()); + posIndex = std::min(posIndex, gradient.height() - 1); + + // pickup appropriate color + color = gradient.pixel(gradient.size().width()/2, posIndex); + } + + // create and apply stylesheet! + // can be customized to change background and handle border! + + if(baseStylesheet.length() == 0) { + // Capture original unmodified stylesheet the first time + baseStylesheet = styleSheet(); + } + + // Append background color + QString stylesheet = baseStylesheet + + "QSlider::sub-page:" + ((orientation() == Qt::Horizontal) ? QString("horizontal"):QString("vertical")) + "{ \ + background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 " + color.darker(130).name() + ", stop: 1 " + color.name() + "); \ + }" + + "QSlider::add-page:" + ((orientation() == Qt::Horizontal) ? QString("horizontal"):QString("vertical")) + "{ \ + background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 " + color.darker(130).name() + ", stop: 1 " + color.name() + "); \ + }"; + + setStyleSheet(stylesheet); +} diff --git a/src/shared/linampslider.h b/src/shared/linampslider.h new file mode 100644 index 0000000..311fbea --- /dev/null +++ b/src/shared/linampslider.h @@ -0,0 +1,26 @@ +#ifndef LINAMPSLIDER_H +#define LINAMPSLIDER_H + +#include +#include +#include + +class LinampSlider : public QSlider +{ + Q_OBJECT +public: + LinampSlider(QWidget* parent); + + void setGradient(QColor from, QColor to, Qt::Orientation orientation); + + void setGradient(QList steps, Qt::Orientation orientation); + +private slots: + void changeColor(int); + +private: + QImage gradient; + QString baseStylesheet; +}; + +#endif // LINAMPSLIDER_H diff --git a/src/view-basewindow/mainwindow.cpp b/src/view-basewindow/mainwindow.cpp index 9b54b27..9e2c806 100644 --- a/src/view-basewindow/mainwindow.cpp +++ b/src/view-basewindow/mainwindow.cpp @@ -32,6 +32,8 @@ const unsigned int WINDOW_H = 117 * UI_SCALE; #include #define slots Q_SLOTS +#define LINAMP_PY_MODULE "linamp" + MainWindow::MainWindow(QWidget *parent) : QMainWindow{parent} { @@ -53,11 +55,13 @@ MainWindow::MainWindow(QWidget *parent) coordinator = new AudioSourceCoordinator(this, player); fileSource = new AudioSourceFile(this, m_playlistModel); - btSource = new AudioSourceBluetooth(this); + btSource = new AudioSourcePython(LINAMP_PY_MODULE, "BTPlayer", this); cdSource = new AudioSourceCD(this); + spotSource = new AudioSourcePython(LINAMP_PY_MODULE, "SpotifyPlayer", this); coordinator->addSource(fileSource, "FILE", true); coordinator->addSource(btSource, "BT", false); coordinator->addSource(cdSource, "CD", false); + coordinator->addSource(spotSource, "SPOT", false); // Connect events diff --git a/src/view-basewindow/mainwindow.h b/src/view-basewindow/mainwindow.h index a121668..5033e7b 100644 --- a/src/view-basewindow/mainwindow.h +++ b/src/view-basewindow/mainwindow.h @@ -4,10 +4,10 @@ #include #include #include -#include "audiosourcebluetooth.h" #include "audiosourcecd.h" #include "audiosourcecoordinator.h" #include "audiosourcefile.h" +#include "audiosourcepython.h" #include "controlbuttonswidget.h" #include "mainmenuview.h" #include "playerview.h" @@ -30,8 +30,9 @@ class MainWindow : public QMainWindow MainMenuView *menu; AudioSourceCoordinator *coordinator; AudioSourceFile *fileSource; - AudioSourceBluetooth *btSource; + AudioSourcePython *btSource; AudioSourceCD *cdSource; + AudioSourcePython *spotSource; public slots: void showPlayer(); diff --git a/src/view-menu/mainmenuview.ui b/src/view-menu/mainmenuview.ui index a59091e..09c6f98 100644 --- a/src/view-menu/mainmenuview.ui +++ b/src/view-menu/mainmenuview.ui @@ -27,198 +27,825 @@ background-color: #333350; } - - - - 40 - 40 - 240 - 220 - + + + 0 - - - DejaVu Sans Mono - 24 - false - true - + + 0 - - QPushButton { - background-color: #bdced6; - border: 4px solid #4a5a6b; - border-radius: 0px; + + 0 + + + 0 + + + 0 + + + + + 8 + + + QLayout::SetDefaultConstraint + + + 36 + + + 24 + + + 22 + + + 8 + + + + + + DejaVu Sans + 24 + false + false + true + + + + QLabel { + color: #D6DEFF; +} + + + Sources + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 56 + 56 + + + + + DejaVu Sans Mono + 12 + true + + + + QPushButton { + background-color: transparent; + border: 0px; + border-radius: 28px; } QPushButton:pressed { - color: #080810; - background-color: #7b8c9c; - border: 4px solid #080810; + border: 0px; + border-radius: 28px; + background-color: #4A4A71; } - - - FILE - - - - - - 640 - 40 - 240 - 220 - - - - - DejaVu Sans Mono - 24 - false - - - - QPushButton { - background-color: #bdced6; - border: 4px solid #4a5a6b; + + + + + + + :/assets/menu-icon-x.png + + + + + 56 + 56 + + + + + + + + + + false + + + QWidget { + background-color: transparent; +} + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + false + + + + + 0 + 0 + 1280 + 312 + + + + + 32 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 14 + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 225 + 225 + + + + + 225 + 225 + + + + + DejaVu Sans Mono + 24 + false + true + + + + QPushButton { + color: #D6DEFF; + background-color: #3F3F60; + border: 0px; border-radius: 0px; } QPushButton:pressed { - color: #080810; - background-color: #7b8c9c; - border: 4px solid #080810; + color: #D6DEFF; + background-color: #4A4A71; + border: 0px; } - - - BLUETOOTH - - - - - - 940 - 40 - 240 - 220 - - - - - DejaVu Sans Mono - 24 - false - - - - QPushButton { - background-color: #bdced6; - border: 4px solid #4a5a6b; + + + + + + + :/assets/source-icon-file.png + + + + + 128 + 128 + + + + + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + + DejaVu Sans + 19 + + + + QLabel { + color: #D6DEFF; +} + + + File + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 0 + 0 + + + + + 14 + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 225 + 225 + + + + + 225 + 225 + + + + + DejaVu Sans Mono + 24 + false + true + + + + QPushButton { + color: #D6DEFF; + background-color: #3F3F60; + border: 0px; border-radius: 0px; } QPushButton:pressed { - color: #080810; - background-color: #7b8c9c; - border: 4px solid #080810; + color: #D6DEFF; + background-color: #4A4A71; + border: 0px; } - - - SPOTIFY - - - - - - 1180 - 310 - 81 - 71 - - - - - DejaVu Sans Mono - 12 - true - - - - QPushButton { - background-color: #bdced6; - border: 4px solid #4a5a6b; + + + + + + + :/assets/source-icon-cd.png + + + + + 128 + 128 + + + + + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + + DejaVu Sans + 19 + + + + QLabel { + color: #D6DEFF; +} + + + CD + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 0 + 0 + + + + + 14 + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 225 + 225 + + + + + 225 + 225 + + + + + DejaVu Sans Mono + 24 + false + true + + + + QPushButton { + color: #D6DEFF; + background-color: #3F3F60; + border: 0px; border-radius: 0px; } QPushButton:pressed { - color: #080810; - background-color: #7b8c9c; - border: 4px solid #080810; + color: #D6DEFF; + background-color: #4A4A71; + border: 0px; } - - - BACK - - - - - - 340 - 40 - 240 - 220 - - - - - DejaVu Sans Mono - 24 - false - true - - - - QPushButton { - background-color: #bdced6; - border: 4px solid #4a5a6b; + + + + + + + :/assets/source-icon-bluetooth.png + + + + + 128 + 128 + + + + + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + + DejaVu Sans + 19 + + + + QLabel { + color: #D6DEFF; +} + + + Bluetooth + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 0 + 0 + + + + + 14 + + + QLayout::SetDefaultConstraint + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 225 + 225 + + + + + 225 + 225 + + + + + DejaVu Sans Mono + 24 + false + true + + + + QPushButton { + color: #D6DEFF; + background-color: #3F3F60; + border: 0px; border-radius: 0px; } QPushButton:pressed { - color: #080810; - background-color: #7b8c9c; - border: 4px solid #080810; + color: #D6DEFF; + background-color: #4A4A71; + border: 0px; } - - - CD - - - - - - 40 - 310 - 361 - 71 - - - - - DejaVu Sans Mono - 48 - false - - - - QLabel { - color: rgb(186, 199, 211); + + + + + + + :/assets/source-icon-spotify.png + + + + + 128 + 128 + + + + + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + + DejaVu Sans + 19 + + + + QLabel { + color: #D6DEFF; } - - - SOURCES - - + + + Spotify + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/view-player/playerview.cpp b/src/view-player/playerview.cpp index c765b17..b4c113e 100644 --- a/src/view-player/playerview.cpp +++ b/src/view-player/playerview.cpp @@ -47,10 +47,38 @@ PlayerView::PlayerView(QWidget *parent, ControlButtonsWidget *ctlBtns) : // Set volume slider ui->volumeSlider->setRange(0, 100); + QList volumeGradientColors{ + QColor::fromRgb(7,191,0), + QColor::fromRgb(28,191,0), + QColor::fromRgb(59,191,0), + QColor::fromRgb(95,191,0), + QColor::fromRgb(132,191,0), + QColor::fromRgb(163,191,0), + QColor::fromRgb(183,191,0), + QColor::fromRgb(191,191,0), + QColor::fromRgb(191,183,0), + QColor::fromRgb(191,163,0), + QColor::fromRgb(191,132,0), + QColor::fromRgb(191,95,0), + QColor::fromRgb(191,59,0), + QColor::fromRgb(191,28,0), + QColor::fromRgb(191,7,0), + QColor::fromRgb(192,0,0), + }; + ui->volumeSlider->setGradient(volumeGradientColors, Qt::Horizontal); connect(ui->volumeSlider, &QSlider::valueChanged, this, &PlayerView::volumeChanged); // Set Balance Slider ui->balanceSlider->setRange(-100, 100); + QList balanceGradientColors; + // balance gradient is two concatenated volumeGradients, but one reversed so green is center + for(int i = volumeGradientColors.count() - 1; i >= 0; i--) { + balanceGradientColors.append(volumeGradientColors[i]); + } + for(int i = 0; i < volumeGradientColors.count(); i++) { + balanceGradientColors.append(volumeGradientColors[i]); + } + ui->balanceSlider->setGradient(balanceGradientColors, Qt::Horizontal); connect(ui->balanceSlider, &QSlider::valueChanged, this, &PlayerView::handleBalanceChanged); // Reset time counter diff --git a/src/view-player/playerview.ui b/src/view-player/playerview.ui index db3ef25..056e2e5 100644 --- a/src/view-player/playerview.ui +++ b/src/view-player/playerview.ui @@ -1224,7 +1224,7 @@ 0 - + 65 @@ -1299,7 +1299,7 @@ QSlider::add-page:horizontal { - + 38 @@ -1593,6 +1593,11 @@ QSlider::groove:horizontal { QLabel
scrolltext.h
+ + LinampSlider + QSlider +
linampslider.h
+
diff --git a/uiassets.qrc b/uiassets.qrc index da106bd..cbe0b75 100644 --- a/uiassets.qrc +++ b/uiassets.qrc @@ -112,5 +112,10 @@ assets/fb_folderIcon_selected.png assets/fb_musicIcon_selected.png assets/logoButton.png + assets/source-icon-bluetooth.png + assets/source-icon-cd.png + assets/source-icon-file.png + assets/source-icon-spotify.png + assets/menu-icon-x.png From eb9b3d1215c3645994131023070bbb674feec1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 30 Dec 2024 12:54:29 -0600 Subject: [PATCH 2/3] Address github actions warning --- .github/workflows/build-deb.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 00c62ef..ed94963 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -9,7 +9,7 @@ on: jobs: build-x86_64: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -27,7 +27,7 @@ jobs: path: debian/artifacts/* build-arm64: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 From 692469959d231dc36222ddb0d4614131b7ddfa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 30 Dec 2024 13:26:48 -0600 Subject: [PATCH 3/3] Fix debian package --- debian/rules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/rules b/debian/rules index 1ccd306..16beb36 100755 --- a/debian/rules +++ b/debian/rules @@ -8,7 +8,7 @@ override_dh_auto_install: cp build/player debian/linamp/usr/bin/linamp-player mkdir -p debian/linamp/usr/lib/python3/dist-packages/linamp - cp -r python/linamp debian/linamp/usr/lib/python3/dist-packages/linamp + cp -r python/linamp debian/linamp/usr/lib/python3/dist-packages/ mkdir -p debian/linamp/usr/share/applications/ cp linamp.desktop debian/linamp/usr/share/applications/