3
\$\begingroup\$

So I have decided to learn Kivy Framework. Did two small apps containing mainly just labels (so that doesn't count) and decided I am going to do a simple music player. Nothing fancy just to learn basics. Right now after learning a few things I already know I should do it a different way. Nevertheless, I kindly ask about your opinion. As to the program, everything works just fine as I planned to. If something is odd it means my thinking is odd ;D I have left empty settings functions there, just thinking about what else I could add. Any advice much appreciated.

enter image description here

File Structure:

enter image description here

main.py

from scr.player import Application

def main() -> None:
    Application().run()

if __name__ == '__main__':
    main()

kivyScreenSize.py

def get_screen_size(appsize: bool=False) -> tuple[int, int] | None:
    import sys
    """
    Returns Monitor size x and y in pixels for desktop platforms, or None for
    mobile platforms.
    """
    if sys.platform == 'linux2' and not appsize:
        import subprocess
        output = subprocess.Popen(
            'xrandr | grep "\\*" | cut -d" " -f4',
            shell=True,
            stdout=subprocess.PIPE).communicate()[0]
        screen_x = int(output.replace('\n', '').split('x')[0])
        screen_y = int(output.replace('\n', '').split('x')[1])
    elif sys.platform == 'win32' and not appsize:
        from win32api import GetSystemMetrics
        screen_x: int = GetSystemMetrics(0)
        screen_y: int = GetSystemMetrics(1)
    elif sys.platform == 'darwin' and not appsize:
        try:
            from AppKit import NSScreen
        except ImportError:
            # iOS
            return None
        frame_size = NSScreen.mainScreen().frame().size
        screen_x = frame_size.width
        screen_y = frame_size.height
    else:
        # For mobile devices, use full screen
        return None
    print(f'Monitor: {screen_x}x{screen_y}')
    return (screen_x, screen_y)

kivyScreenSize.py is not entirely mine. I have done that one with a tutorial a long time ago to use it with the Tkinter apps as I could get screen size from any OS with this one (apparently, haven't tried it on all but it does work on on Win 10, 11, Ubuntu and Kali).

player.py

from kivy.config import Config
Config.set('graphics', 'resizable', False)
from typing import Any
from kivymd.app import MDApp
from kivy.core.window import Window
from kivy.uix.relativelayout import RelativeLayout
from scr.kivyScreenSize import get_screen_size
from kivymd.uix.list import OneLineListItem
from kivymd.uix.filemanager import MDFileManager
from kivy.core.audio import SoundLoader
from kivy.clock import Clock
import random
import os
import pathlib

class KivyPlayer(RelativeLayout):
    def __init__(self, **kwargs: Any) -> None:
        super(KivyPlayer, self).__init__(**kwargs)
        
        self.is_playing = False
        self.screen_size: tuple[int, int] | None = get_screen_size(appsize=False)
        if self.screen_size != None:
            Window.size = (700, 400)
        
        self.manager_open = False
        self.audio_ext = ['.mp3', '.wav']
        self.file_manager = MDFileManager(
            exit_manager=self.exit_manager,
            select_path=self.select_path,
            preview=False,
            ext=self.audio_ext)
        
        self.directory = ''
        self.playlist_index = 0
        self.playlist = []
        # self.playing_position = time of currently playing song.
        # will enable to resume playing song from this position.
        self.playing_position = 0
        self.loop_audio = False
        self.shuffle_audio = False

    def start_new_audio(self) -> None:
        self.ids.progress_bar.value = 0
        self.sound = SoundLoader.load(self.playlist[self.playlist_index])
        self.ids.audio_length_label.text = self.get_audio_length()
        self.ids.title_label.text = pathlib.Path(self.playlist[self.playlist_index]).stem
        self.sound.play()
        self.is_playing = True
    
    def play_pause(self) -> None:
        try:
            if self.is_playing:
                self.is_playing = False
                self.ids.play_btn.background_normal = './res/icons/pause.png'
                self.ids.play_btn.background_down = './res/icons/play.png'
                self.playing_position = self.sound.get_pos()
                self.sound.stop()
            else:
                self.is_playing = True
                self.ids.play_btn.background_normal = './res/icons/play.png'
                self.ids.play_btn.background_down = './res/icons/pause.png'
                # resume playing from last position
                if self.playing_position != 0:
                    self.sound.play()
                    self.sound.seek(self.playing_position)
                else:
                    self.start_new_audio()
        except:
            # If playlist is empty nothing will happen.
            pass
        
    def change_audio(self, option: str) -> None:
        try:
            self.sound.stop()
            self.sound.unload()
            match option:
                case 'next':
                    self.playlist_index += 1
                case 'prev':
                    self.playlist_index -= 1
                case _:
                    pass
            if self.playlist_index >= len(self.playlist):
                self.playlist_index = 0
            elif self.playlist_index < 0:
                self.playlist_index = len(self.playlist) - 1
            if self.is_playing:
                self.start_new_audio()
        except:
            pass
    
    def settings(self) -> None:
        pass
    
    def loop(self) -> None:
        # turn off shuffle
        if self.shuffle_audio:
            self.shuffle()
        if self.loop_audio:
            self.loop_audio = False
            self.ids.loop_btn.background_normal = './res/icons/loop.png'
            self.ids.loop_btn.background_down = './res/icons/repeat.png'
        else:
            self.loop_audio = True
            self.ids.loop_btn.background_normal = './res/icons/repeat.png'
            self.ids.loop_btn.background_down = './res/icons/loop.png'
    
    def shuffle(self) -> None:
        # turn off looping
        if self.loop_audio:
            self.loop()
        if self.shuffle_audio:
            self.shuffle_audio = False
            self.ids.shuffle_btn.background_normal = './res/icons/shuffle.png'
            self.ids.shuffle_btn.background_down = './res/icons/shuffle_down.png'
        else:
            self.shuffle_audio = True
            self.ids.shuffle_btn.background_normal = './res/icons/shuffle_down.png'
            self.ids.shuffle_btn.background_down = './res/icons/shuffle.png'
    
    def on_slider_move(self) -> None:
        try:
            self.playing_position = self.ids.progress_bar.value
            self.sound.seek(self.playing_position)
        except:
            pass
        
    def on_click_playlist(self, list_item: Any) -> None:
        item = list_item.text.replace('[size=11]', '').replace('[/size]', '')
        self.is_playing = True
        for i in self.playlist:
            if item == pathlib.Path(i).stem:
                try:
                    if self.sound:
                        self.sound.stop()
                        self.sound.unload()
                    self.playing_position = 0
                    self.playlist_index = self.playlist.index(i)
                    self.start_new_audio()
                except:
                    pass
    
    def update_audio_pos(self, *args: Any) -> None:
        try:
            if self.sound:
                time_sec = self.sound.get_pos()
                self.ids.audio_pos_label.text = self.secs_to_str_time(time_sec=time_sec)
                self.ids.progress_bar.max = round(self.sound.length)
                self.ids.progress_bar.value = round(time_sec)
                if self.sound.state == 'stop':
                    if self.shuffle_audio:
                        self.playlist_index = random.randint(0, len(self.playlist) - 1)
                        self.start_new_audio()
                    elif self.loop_audio:
                        self.playing_position = 0
                        # double call to play again
                        # first call will turn self.is_playing to False
                        self.play_pause()
                        self.play_pause()
                    else:
                        self.change_audio('next')
        except:
            self.ids.audio_pos_label.text = '00:00'
    
    def get_audio_length(self) -> str:
        if self.sound:
            time_sec = self.sound.length
            return self.secs_to_str_time(time_sec=time_sec)
        return '00:00'
    
    def secs_to_str_time(self, time_sec: float) -> str:
        """Return time in format '00:00' from seconds."""
        time_min = int(time_sec // 60)
        hours = int(time_min // 60)
        minutes = int(time_min % 60)
        sec = int(time_sec % 60)
        sec = round(sec)
        if len(str(sec)) == 1:
            sec = f'0{sec}'
        if len(str(minutes)) == 1:
            minutes = f'0{minutes}'
        if hours == 0:
            return f'{minutes}:{sec}'
        return f'{hours}:{minutes}:{sec}'
    
    def add_to_playlist(self) -> None:
        for i in self.playlist:
            name = pathlib.Path(i).stem
            self.ids.playlist.add_widget(OneLineListItem(text=f'[size=11]{name}[/size]',
                                                         divider='Inset',
                                                         on_release=self.on_click_playlist
                                                        ))
        if len(self.playlist) > 0:
            self.playlist_index = 0
            self.start_new_audio()

    # KivyMD File Manager  
    def file_manager_open(self) -> None:
        self.manager_open = True
        self.file_manager.show('/')

    def select_path(self, path: str) -> None:
        self.directory = path
        if os.path.isdir(self.directory):
            self.playlist = []
            self.ids.playlist.clear_widgets()
            for i in os.listdir(self.directory):
                if pathlib.Path(i).suffix in self.audio_ext:
                    self.playlist.append(f'{self.directory}/{i}')
        else:
            self.playlist.append(self.directory)
        self.exit_manager()
        
    def exit_manager(self, *args: Any) -> None:
        self.manger_open = False
        self.file_manager.close()
        self.add_to_playlist()
        print(self.directory)
       
class Application(MDApp):
    title = 'KivyPlayer'
    icon = './res/icon.png'
    Window.clearcolor = (68/255.0, 122/255.0, 156/255.0, 0.8)
    
    def build(self) -> RelativeLayout:
        _app = KivyPlayer()
        Clock.schedule_interval(_app.update_audio_pos, 1)
        return _app

application.kv

<KivyPlayer>
    canvas.before:
        Color:
            rgba: (68/255.0, 122/255.0, 156/255.0, 0.2)
        Rectangle:
            pos: self.pos
            size: self.size
    Button:
        id: play_btn
        background_normal: './res/icons/play.png'
        background_down: './res/icons/play.png'
        pos_hint: {'x': 0.2, 'y': 0.04}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.15
        on_press: root.play_pause()
    Button:
        id: next_btn
        background_normal: './res/icons/next.png'
        background_down: './res/icons/next.png'
        pos_hint: {'x': 0.29, 'y': 0.04}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.15
        on_press: root.change_audio('next')
    Button:
        id: prev_btn
        background_normal: './res/icons/prev.png'
        background_down: './res/icons/prev.png'
        pos_hint: {'x': 0.11, 'y': 0.04}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.15
        on_press: root.change_audio('prev')
    Button:
        id: loop_btn
        background_normal: './res/icons/loop.png'
        background_down: './res/icons/repeat.png'
        pos_hint: {'x': 0.38, 'y': 0.04}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.15
        on_press: root.loop()
    Button:
        id: shuffle_btn
        background_normal: './res/icons/shuffle.png'
        background_down: './res/icons/shuffle_down.png'
        pos_hint: {'x': 0.02, 'y': 0.04}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.15
        on_press: root.shuffle()
    Button:
        id: add_btn
        background_normal: './res/icons/add.png'
        background_down: './res/icons/add.png'
        pos_hint: {'x': 0.88, 'y': 0.01}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.1, self.parent.height * 0.14
        on_press: root.file_manager_open()
    Label:
        id: audio_length_label
        text: '00:00'
        pos_hint: {'x': 0.4, 'y': 0.12}
        font_size: 9
        color: (0, 0, 0, 1)
        size_hint: None, None
    Label:
        id: audio_pos_label
        text: '00:00'
        pos_hint: {'x': 0.4, 'y': 0.18}
        font_size: 9
        color: (0, 0, 0, 1)
        size_hint: None, None
    Label:
        id: title_label
        text: 'No file selected'
        pos_hint: {'x': 0.03, 'y': 0.29}
        halign: 'center'
        font_size: 11
        color: (0, 0, 0, 1)
        size_hint: 0.4, 0.05
        text_size: self.size
        shorten: True
    Slider:
        id: progress_bar
        min: 0
        max: 0
        step: 1
        value: 0
        orientation: 'horizontal'
        size_hint: 0.5, 0.05
        pos_hint: {'x': 0.001, 'y': 0.25}
        cursor_size: 0, 0
        value_track: True
        value_track_color: (68/255.0, 122/255.0, 156/255.0, 1)
        value_track_width: 4
        on_touch_up: root.on_slider_move()
    Image:
        id: cover_img
        source: './res/icon.png'
        pos_hint: {'x': 0.001, 'y': 0.4}
        keep_ratio: True
        allow_stretch: True
        size_hint: None, None
        size: self.parent.width * 0.5, self.parent.height * 0.5    

    ScrollView:
        size_hint: 0.45, 0.82
        pos_hint: {'x': 0.53, 'y': 0.15}
        canvas.before:
            Color:
                rgba: 68/255.0, 122/255.0, 156/255.0, 1
            RoundedRectangle:
                pos: self.pos
                size: self.size
        MDList:
            id: playlist
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$
  1. It is better to use snake case in file names change kivyScreenSize to kivy_screen_size.py
  2. It is better to use MVC pattern in your app, see: https://kivymd.readthedocs.io/en/latest/api/kivymd/tools/patterns/create_project/index.html
  3. Instead of None use NoReturn from typing.
  4. Don't use match case it is 6 time slower then common if else(I and many people made tests)
  5. Don't use Config.set in imports section.
  6. Instead of rgba you can use get_color_from_hex or primary color from kivymd.
  7. You have incorrect doc string in your function read PEP 257
\$\endgroup\$
6
  • \$\begingroup\$ 1. If more than 3 words I keep camel Case, snake case looks obscure when really long.. 2. MVC pattern is sort of kept, imported modules are the brain of it all everything else is just GUI and control, 4. match case is actually faster, 5. that's it what kivy says, use it before imports. 6. I prefer rgba. Thank you for your time \$\endgroup\$
    – Jakub
    Apr 9, 2022 at 7:18
  • 1
    \$\begingroup\$ 3. please read this stackoverflow.com/questions/64063732/… my functions are of a type of none mostly, so they are returning None. \$\endgroup\$
    – Jakub
    Apr 9, 2022 at 7:29
  • \$\begingroup\$ @Jakub 1. Can you send link or name rule of PEP 8 where this was written? 4. No unfortunately it isn't I can send you some researches. 5. Ok, it might be, but add some space between imports and Config.set. 3. Ok, I agree if there isn't return function returns None, If you want you can write None. \$\endgroup\$
    – Kram
    Apr 9, 2022 at 7:48
  • \$\begingroup\$ there is not rule for it. I clearly stated this is what I do. Yes I don't like long names in snake case so in my files I keep it as camel Case. My own preference. kivy_screen_size. py would look just obscure in my opinion that's why in my folders I keep it as I like it. If it was work related etc I would change it to snake case. \$\endgroup\$
    – Jakub
    Apr 9, 2022 at 8:16
  • 1
    \$\begingroup\$ Jakub's use of None as a return type is, in context, correct; and suggesting NoReturn is not. NoReturn is reserved for the case that the method throws before completing. \$\endgroup\$
    – Reinderien
    Apr 10, 2022 at 21:05

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.