🎧 Media Player
Songs, podcasts, audiobooks and video clips — four different kinds of media, each with a completely
different idea of what play(), pause() and duration() mean.
Yet the transport bar below calls exactly the same three methods on every one of them.
This is method overriding in action: a single MediaItem base class defines the interface,
each subclass overrides the behaviour, and the player loop never asks "what are you?". In this lesson you will
play with it, build it from scratch, break it on purpose, then prove you understand it.
By the end you can…
- Explain polymorphism in one sentence
- Override methods and call
super()correctly - Use
ABCto enforce a contract - Replace
isinstanceladders with one call - Choose between inheritance and duck typing
You should already know
- Defining classes and
__init__ - Basic inheritance (
class B(A)) - f-strings, loops and lists
New to classes? Skim the Polymorphism Guide first.
One Transport Bar, Four Media Kinds
Click any track to load it. Press ▶ to play, ⏸ to pause, or click the progress bar to seek. Watch the trace panel at the bottom — every action calls a method that the object's own class implements differently.
Try this experiment (2 minutes): ① Play each of the four tracks and compare the trace lines. ② Press ➕ Add Lecture and play it — the player code did not change. ③ Press 🦆 Duck-type test to add an object that does not inherit from MediaItem. ④ Press ⚠ Call base play() to see what the abstract base class does.
📚 Library
each row = a different subclassWhat to notice: the controls section only calls item.play(), item.pause() and item.duration(). The base class MediaItem declares these methods; each subclass (Song, Podcast, Audiobook, VideoClip) overrides them with its own logic. The player never checks the type.
What Is Polymorphism, Really?
The word comes from Greek: poly (many) + morph (forms). In programming it means one interface, many implementations. The caller says what it wants done; each object decides how.
🔌 Real-world analogy: the wall socket
A wall socket promises one thing: "plug in and receive power." A lamp, a phone charger and a fan all plug into the same socket, and each does something completely different with the electricity. The socket never needs to know which device is attached. Your media player is the socket; Song, Podcast and friends are the devices.
The contract: every MediaItem promises play(), pause() and duration(). As long as the promise holds, the player works with any item — including ones that do not exist yet.
The four faces of polymorphism in Python
Method overriding
A subclass replaces a parent method. This page.
class Song(MediaItem)Duck typing
"If it quacks like a duck…" No shared parent needed.
obj.play()Operator overloading
Your class defines what +, ==, < mean.
__add__, __lt__Single dispatch
One function name, behaviour chosen by argument type.
@singledispatchWhy bother? Three payoffs
- Open for extension, closed for modification. Add a
Lectureclass without touching the player (the Open/Closed Principle). - Less branching. No growing
if/elifladder that must be edited in every function that handles media. - Easier testing. Swap in a fake item that records calls — the player cannot tell the difference.
Liskov Substitution Principle in one line: anywhere your code expects a MediaItem, any subclass must work without surprises. Overrides may change behaviour, but not the promise (names, arguments, return type).
From a Messy Ladder to Clean Polymorphism
Press Run on every step. Each block is self-contained, so you can also edit it in the playground later. (The first run loads Python in your browser and takes a few seconds.)
Start with the problem
Without polymorphism, the player must know every media type. Notice how play_item already needs editing each time a new kind appears.
def play_item(item):
if item["kind"] == "song":
return f"🎵 Now playing {item['title']}"
elif item["kind"] == "podcast":
return f"🎙 Episode {item['episode']}: {item['title']}"
elif item["kind"] == "audiobook":
return f"📖 Chapter {item['chapter']} of {item['title']}"
else:
return "❓ Unknown media type"
library = [
{"kind": "song", "title": "Sunrise"},
{"kind": "podcast", "title": "Why OOP Matters", "episode": 12},
{"kind": "video", "title": "Polymorphism Explained"},
]
for item in library:
print(play_item(item))The smell: the video fell into else and printed "Unknown". Every new kind means editing this function — and every other function with a similar ladder.
Define the contract with a base class
We declare what every media item must do, and leave how to the subclasses. @abstractmethod turns the promise into a rule Python enforces.
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title = title
self.creator = creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
try:
MediaItem("x", "y")
except TypeError as e:
print("TypeError:", e)Write the first subclass
A subclass overrides each abstract method. Use super().__init__() to reuse the parent's setup instead of copying it.
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title, self.creator = title, creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist)
self.seconds = seconds
def play(self): return f"🎵 Now playing '{self.title}' by {self.creator}"
def pause(self): return f"⏸ Song paused at chorus of '{self.title}'"
def duration(self): return self.seconds
s = Song("Sunrise", "The Harbors", 213)
print(s.play())
print(s.pause())
print(isinstance(s, MediaItem))Add more kinds and use one loop
Now the payoff. Two more subclasses, and the loop is identical for all of them.
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title, self.creator = title, creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist); self.seconds = seconds
def play(self): return f"🎵 Now playing '{self.title}'"
def pause(self): return "⏸ Song paused"
def duration(self): return self.seconds
class Podcast(MediaItem):
def __init__(self, title, host, seconds, episode):
super().__init__(title, host); self.seconds = seconds; self.episode = episode
def play(self): return f"🎙 Episode {self.episode}: '{self.title}'"
def pause(self): return f"⏸ Bookmarked at episode {self.episode}"
def duration(self): return self.seconds
library = [Song("Sunrise", "The Harbors", 213),
Podcast("Why OOP Matters", "Byte Talk", 1540, 12)]
for item in library:
print(item.play())
print(item.pause())Extend without touching the loop
This is the Open/Closed Principle proving itself. We add a brand-new Lecture class and the loop from step 4 stays exactly the same.
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title, self.creator = title, creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist); self.seconds = seconds
def play(self): return f"🎵 Now playing '{self.title}'"
def pause(self): return "⏸ Song paused"
def duration(self): return self.seconds
class Lecture(MediaItem):
def __init__(self, title, professor, seconds, week):
super().__init__(title, professor); self.seconds = seconds; self.week = week
def play(self): return f"🎓 Week {self.week}: '{self.title}' with Prof. {self.creator}"
def pause(self): return f"⏸ Lecture paused — notes saved for week {self.week}"
def duration(self): return self.seconds
library = [Song("Sunrise", "The Harbors", 213),
Lecture("Intro to Polymorphism", "Reyes", 3000, 4)]
for item in library:
print(item.play())Milestone reached. You just used inheritance, overriding, super(), an abstract contract and the Open/Closed Principle. The ➕ Add Lecture button in the live player does the same thing in JavaScript.
The Same App, in Python OOP
This is the exact model that runs the player above — a MediaItem base class and four subclasses, each overriding play(), pause(), duration() and info(). Press Run to execute it in your browser with Pyodide.
media_player.py
from abc import ABC, abstractmethod
class MediaItem(ABC):
"""Base contract: every media item can play, pause, and report its duration."""
def __init__(self, title, creator):
self.title = title
self.creator = creator
@abstractmethod
def play(self):
...
@abstractmethod
def pause(self):
...
@abstractmethod
def duration(self):
"""Return duration in seconds."""
def info(self):
m, s = divmod(int(self.duration()), 60)
return f"{type(self).__name__:<10} {self.title!r} by {self.creator} ({m}:{s:02d})"
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist)
self.seconds = seconds
def play(self):
return f"🎵 Now playing '{self.title}' by {self.creator}"
def pause(self):
return f"⏸ Song paused at chorus of '{self.title}'"
def duration(self):
return self.seconds
class Podcast(MediaItem):
def __init__(self, title, host, seconds, episode):
super().__init__(title, host)
self.seconds = seconds
self.episode = episode
def play(self):
return f"🎙 Episode {self.episode}: '{self.title}' with {self.creator}"
def pause(self):
return f"⏸ Podcast paused — bookmarked at episode {self.episode}"
def duration(self):
return self.seconds
class Audiobook(MediaItem):
def __init__(self, title, narrator, seconds, chapter):
super().__init__(title, narrator)
self.seconds = seconds
self.chapter = chapter
def play(self):
return f"📖 Chapter {self.chapter} of '{self.title}', read by {self.creator}"
def pause(self):
return f"⏸ Audiobook closed at chapter {self.chapter}"
def duration(self):
return self.seconds
class VideoClip(MediaItem):
def __init__(self, title, channel, seconds, resolution="1080p"):
super().__init__(title, channel)
self.seconds = seconds
self.resolution = resolution
def play(self):
return f"🎬 Streaming '{self.title}' from {self.creator} at {self.resolution}"
def pause(self):
return f"⏸ Video paused at {self.resolution}"
def duration(self):
return self.seconds
library = [
Song("Sunrise", "The Harbors", 213),
Podcast("Why OOP Matters", "Byte Talk", 1540, 12),
Audiobook("Pythonic Patterns", "R. Domingo", 7200, 3),
VideoClip("Polymorphism Explained", "Poetic Bytes", 480, "4K"),
]
print("─" * 50)
for item in library:
print(item.info())
print("─" * 50)
for item in library:
print(item.play())
print(item.pause())
print("─" * 50)
print("Total library time:", sum(i.duration() for i in library), "seconds")
Why this works: item.play() appears once in the loop, but Python dispatches it to Song.play, Podcast.play, Audiobook.play or VideoClip.play depending on the object's real class. Adding a Lecture class later means writing the class — the loop stays untouched.
🔍 Code-reading guide
- Lines 1–2:
ABCandabstractmethodcome from the standard library'sabcmodule. MediaItem.__init__: stores the data every item shares, so subclasses do not repeat it.info(): a concrete method in the base class. It callsself.duration(), which resolves to whichever subclass the object really is.super().__init__(title, artist): hands shared setup to the parent, then each subclass adds its own fields.- The final loops: no
isinstance, noif. That absence is the whole lesson.
How the Player Chooses a Method
The Dispatch Table
When the player calls item.play(), Python searches the object's Method Resolution Order for a play attribute. Here is what it finds for each media kind:
| Object | MRO search | Resolves to | Behaviour |
|---|---|---|---|
Song | Song → MediaItem → ABC → object | Song.play | Title + artist message |
Podcast | Podcast → MediaItem → ABC → object | Podcast.play | Episode-numbered message |
Audiobook | Audiobook → MediaItem → ABC → object | Audiobook.play | Chapter-numbered message |
VideoClip | VideoClip → MediaItem → ABC → object | VideoClip.play | Resolution-aware message |
You can inspect this yourself — every class stores its search order in __mro__:
from abc import ABC, abstractmethod
class MediaItem(ABC):
@abstractmethod
def play(self): ...
class Song(MediaItem):
def play(self): return "song"
for cls in Song.__mro__:
print(cls.__name__)
print()
print("play defined on Song itself?", "play" in vars(Song))
print("info defined on Song itself?", "info" in vars(Song))Why the Base Class Declares @abstractmethod
MediaItem cannot be instantiated directly — pressing ⚠ Call base play() in the player demonstrates the equivalent: the base play() has no implementation, so it refuses to run. That guarantees every item in the library actually has a working play().
Without the abstract method, a subclass could "forget" to override play() and inherit a stub that silently does nothing — a classic late-night bug. With ABC + @abstractmethod, the error is loud and immediate at instantiation.
Template Method: overriding part of the behaviour
Notice info() is written once in the base class, yet it prints different durations per subclass. It calls self.duration(), and Python picks the override. This is called the Template Method pattern: the parent defines the skeleton, children fill in the steps. Overrides can also extend a parent method by calling super():
class MediaItem:
def __init__(self, title):
self.title = title
def info(self):
return f"{self.title}"
class VideoClip(MediaItem):
def __init__(self, title, resolution):
super().__init__(title)
self.resolution = resolution
def info(self):
base = super().info()
return f"{base} [{self.resolution}]"
print(MediaItem("Intro").info())
print(VideoClip("Intro", "4K").info())Duck Typing Means You Don't Even Need the Base Class
The 🦆 Duck-type test button injects a completely unrelated class — no inheritance from MediaItem — and the player still works, because it only calls .play(), .pause() and .duration(). That's the duck-typing version of the same polymorphism.
class Platypus:
def play(self): return "🦆 Platypus Stream"
def pause(self): return "⏸ Platypus paused"
def duration(self): return 60
def run(item):
print(item.play(), "|", item.duration(), "s")
run(Platypus())Rule of thumb: use inheritance when subclasses share code (here: the info() formatting and the title / creator fields). Use duck typing when you want unrelated objects to plug in — like a test double or a plugin.
A Modern Middle Path: typing.Protocol
Since Python 3.8 you can describe the interface without inheritance, and let type checkers (mypy, Pyright) verify duck-typed objects. Nothing is enforced at runtime, but your editor warns you before you ship the bug.
from typing import Protocol
class Playable(Protocol):
def play(self) -> str: ...
def pause(self) -> str: ...
def duration(self) -> int: ...
class Jingle:
def play(self): return "🔔 ding"
def pause(self): return "⏸"
def duration(self): return 3
def start(item: Playable) -> None:
print(item.play())
start(Jingle())| Approach | Enforced when? | Shares code? | Best for |
|---|---|---|---|
ABC + inheritance | At instantiation (runtime) | Yes | Families of related classes |
| Plain duck typing | Never — fails when called | No | Small scripts, plugins, test doubles |
Protocol | By the type checker (before running) | No | Large codebases, library APIs |
Side-by-Side: Ladder vs Polymorphism
# ✗ Ladder version — every new media kind edits the player def play_item(item): if isinstance(item, Song): return "🎵 Now playing" elif isinstance(item, Podcast): return "🎙 Episode" elif isinstance(item, Audiobook): return "📖 Chapter" # add VideoClip → edit here, and every other ladder too # ✓ Polymorphic version — one call, zero edits for new types for item in library: item.play()
Four Classic Polymorphism Mistakes
The fastest way to understand a rule is to watch it fail. Run each broken example, read the error, then run the fix.
Mistake 1 — Forgetting to override an abstract method
from abc import ABC, abstractmethod
class MediaItem(ABC):
@abstractmethod
def play(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def play(self):
return "playing"
Song()Python refuses to create the object and names the missing method. That is the contract doing its job.
from abc import ABC, abstractmethod
class MediaItem(ABC):
@abstractmethod
def play(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def play(self): return "playing"
def duration(self): return 213
print(Song().play(), Song().duration())Mistake 2 — Changing the method signature in the override
Python will not stop you, but you have broken the Liskov Substitution Principle: callers written for the base class now crash for one subclass.
class Song:
def play(self):
return "song playing"
class Podcast(Song):
def play(self, episode):
return f"episode {episode}"
for item in [Song(), Podcast()]:
print(item.play())class Song:
def play(self):
return "song playing"
class Podcast(Song):
def __init__(self, episode=1):
self.episode = episode
def play(self):
return f"episode {self.episode}"
for item in [Song(), Podcast(12)]:
print(item.play()) Mistake 3 — Rebuilding the ladder with isinstance
If you catch yourself writing isinstance to decide which behaviour to run, the behaviour probably belongs inside the classes.
class Song: pass
class Podcast: pass
class Lecture: pass
def label(item):
if isinstance(item, Song): return "🎵"
elif isinstance(item, Podcast): return "🎙"
return "❓"
print([label(x) for x in (Song(), Podcast(), Lecture())])class Song: icon = "🎵" class Podcast: icon = "🎙" class Lecture: icon = "🎓" print([x.icon for x in (Song(), Podcast(), Lecture())])
Mistake 4 — Forgetting super().__init__()
When a subclass defines its own __init__, the parent's is not called automatically. Shared fields never get set.
class MediaItem:
def __init__(self, title, creator):
self.title = title
self.creator = creator
class Song(MediaItem):
def __init__(self, title, artist, seconds):
self.seconds = seconds
s = Song("Sunrise", "The Harbors", 213)
print(s.title)class MediaItem:
def __init__(self, title, creator):
self.title = title
self.creator = creator
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist)
self.seconds = seconds
s = Song("Sunrise", "The Harbors", 213)
print(s.title, "-", s.creator)Debug habit: when a method does something unexpected, print type(obj).__mro__ and type(obj).play. They tell you exactly which version Python picked.
Four Challenges and a Playground
Reading builds recognition; writing builds skill. Try each challenge in the playground below before opening the hint or the solution.
Easy Challenge 1 — Add a Radio class
Create Radio(MediaItem) with a station attribute. play() should return "📻 Tuned to {station}", pause() should return "⏸ Radio muted", and duration() should return 0 (live streams never end). Do not modify the loop.
💡 Hint
Copy the structure of Song. Call super().__init__(title, creator), then set self.station. Return a constant from duration().
✅ Solution
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title, self.creator = title, creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
class Radio(MediaItem):
def __init__(self, title, host, station):
super().__init__(title, host)
self.station = station
def play(self): return f"📻 Tuned to {self.station}"
def pause(self): return "⏸ Radio muted"
def duration(self): return 0
for item in [Radio("Morning Show", "DJ Ana", "96.7 FM")]:
print(item.play(), item.duration())Medium Challenge 2 — A Playlist that is itself playable
Build Playlist(MediaItem) that holds a list of items. Its duration() must return the sum of its children, and its play() must return the play() text of every child joined by newlines. Because a playlist is a MediaItem, you should be able to put a playlist inside another playlist.
💡 Hint
Store self.items = list(items). Use sum(i.duration() for i in self.items). This is the Composite pattern: the container and its contents share one interface.
✅ Solution
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator):
self.title, self.creator = title, creator
@abstractmethod
def play(self): ...
@abstractmethod
def pause(self): ...
@abstractmethod
def duration(self): ...
class Song(MediaItem):
def __init__(self, title, artist, seconds):
super().__init__(title, artist); self.seconds = seconds
def play(self): return f"🎵 {self.title}"
def pause(self): return "⏸"
def duration(self): return self.seconds
class Playlist(MediaItem):
def __init__(self, title, items):
super().__init__(title, "You")
self.items = list(items)
def play(self): return "\n".join(i.play() for i in self.items)
def pause(self): return "⏸ Playlist paused"
def duration(self): return sum(i.duration() for i in self.items)
inner = Playlist("Chill", [Song("A", "x", 100), Song("B", "y", 200)])
outer = Playlist("Everything", [inner, Song("C", "z", 50)])
print(outer.play())
print("Total:", outer.duration(), "seconds")Medium Challenge 3 — Sort the library by duration
Make sorted(library) work by giving MediaItem a __lt__ method that compares durations. This combines method overriding with operator overloading — two flavours of polymorphism in one feature.
💡 Hint
sorted() only needs __lt__. Define it once on the base class: return self.duration() < other.duration().
✅ Solution
from abc import ABC, abstractmethod
class MediaItem(ABC):
def __init__(self, title, creator, seconds):
self.title, self.creator, self.seconds = title, creator, seconds
@abstractmethod
def play(self): ...
def duration(self): return self.seconds
def __lt__(self, other):
return self.duration() < other.duration()
def __repr__(self):
return f"{type(self).__name__}({self.title!r}, {self.duration()}s)"
class Song(MediaItem):
def play(self): return "🎵"
class Podcast(MediaItem):
def play(self): return "🎙"
library = [Podcast("Long", "a", 1540), Song("Short", "b", 213), Song("Mid", "c", 480)]
print(sorted(library))Hard Challenge 4 — Refactor the ladder
The code below works but breaks every time a new shape arrives. Refactor it so each class computes its own area and total_area contains no isinstance. Then add a Triangle without editing total_area.
import math
class Circle:
def __init__(self, r): self.r = r
class Square:
def __init__(self, s): self.s = s
def total_area(shapes):
total = 0
for sh in shapes:
if isinstance(sh, Circle):
total += math.pi * sh.r ** 2
elif isinstance(sh, Square):
total += sh.s ** 2
return total
print(round(total_area([Circle(1), Square(2)]), 2))💡 Hint
Move each formula into an area() method on its class. Then total_area becomes sum(sh.area() for sh in shapes).
✅ Solution
import math
class Circle:
def __init__(self, r): self.r = r
def area(self): return math.pi * self.r ** 2
class Square:
def __init__(self, s): self.s = s
def area(self): return self.s ** 2
class Triangle:
def __init__(self, b, h): self.b, self.h = b, h
def area(self): return 0.5 * self.b * self.h
def total_area(shapes):
return sum(sh.area() for sh in shapes)
print(round(total_area([Circle(1), Square(2), Triangle(3, 4)]), 2))🧪 Playground — write your own code
Edit freely, then press Run. Starter code is below; solve the challenges here. Press Ctrl/⌘ + Enter to run quickly.
Check Your Understanding
Eight questions. You get instant feedback and an explanation after each answer. Your best score is saved in this browser.
Key Takeaways
- One interface, many forms. Callers rely on method names; objects decide behaviour.
- Overriding replaces a parent method in a subclass. Keep the same signature and return type.
ABC+@abstractmethodturns a promise into an enforced rule, failing loudly at instantiation.super()lets a child reuse or extend parent behaviour instead of copying it.- Duck typing and
Protocolgive polymorphism without inheritance. - Smell test: an
isinstanceladder usually means behaviour belongs inside the classes.
Where You See This in Real Software
Databases
One connection.execute() API; PostgreSQL, SQLite and MySQL drivers each implement it differently.
Games
Every enemy has update() and draw(); the game loop calls them on a mixed list.
Payments
gateway.charge() hides whether it talks to Stripe, PayPal or GCash.
File-like objects
Anything with .read() works: files, network sockets, in-memory buffers.
Glossary
Using one interface to work with objects of different classes.
Redefining an inherited method in a subclass.
A class that cannot be instantiated until its abstract methods are implemented.
Method Resolution Order: the sequence Python searches to find a method.
A proxy to the parent class, used to reuse its methods.
Judging an object by the methods it has, not by its class.
A type-hint-only interface matched by shape, not inheritance.
Subclasses must be usable wherever the parent is expected.
Open for extension, closed for modification.
Parent defines the algorithm skeleton; children supply the steps.
Frequently Asked Questions
Is polymorphism the same as inheritance?
No. Inheritance is a mechanism for sharing code and a type relationship. Polymorphism is the benefit of treating different objects uniformly. Python gets polymorphism from inheritance, but also from duck typing, which needs no inheritance at all.
Do I always need an abstract base class?
No. Use ABC when you control a family of classes and want errors early. For small scripts or plugin-style code, duck typing is simpler. For large typed codebases, consider Protocol.
What is the difference between overriding and overloading?
Overriding replaces a parent's method in a subclass (this lesson). Overloading means several functions sharing a name with different parameters. Python has no built-in overloading by signature; use default arguments, *args, or @singledispatch instead. "Operator overloading" (__add__) is a separate, Python-supported idea.
Does polymorphism make code slower?
The lookup cost is tiny and Python caches it. In practice the clarity and maintainability gains far outweigh any difference. Measure before optimising.
Why does the base class have a duration() docstring but no body?
A function whose body is only a docstring (or ...) is valid and returns None. Because the method is abstract, subclasses must override it anyway, so the stub is never reached through a normal instance.
Where should I go next?
Continue with the other nine projects below. A good order: Shape Renderer (same overriding idea, simpler), Test-Double Lab (duck typing for testing), then Currency Converter (operator overloading).
10 Live Projects
You're looking at Project 05. Here are the other nine — each demonstrates a different flavor of polymorphism.
| # | Project | Flavor |
|---|---|---|
| 01 | 🔷 Shape Renderer | Method overriding |
| 02 | 🖼️ Image Filter Plugin | Duck typing |
| 03 | 💱 Currency Converter | Operator overloading |
| 04 | 🍳 Recipe Formatter | Single dispatch |
| 05 | 🎧 Media Player | Method overriding ← you are here |
| 06 | 🧪 Test-Double Lab | Duck typing |
| 07 | 📒 Double-Entry Ledger | Operator overloading |
| 08 | 📨 Log Router | Single dispatch |
| 09 | ♞ Chess Pieces | Method overriding |
| 10 | ✅ Protocol Checker | Duck typing |